mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-07-31 04:15:51 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33afa9876f | |||
| 038623325c |
@@ -329,6 +329,63 @@ id: 'aidx'
|
||||
# #tracePropagationTargets:
|
||||
# # - 'internal-service.example'
|
||||
|
||||
# ┌─────────┐
|
||||
#───┘ Tracing └────────────────────────────────────────────────
|
||||
# OpenTelemetry distributed tracing (Traces only, opt-in).
|
||||
# Existing API and Queue spans are exported to an OTLP http/protobuf endpoint.
|
||||
#
|
||||
# Standard OTEL_* environment variables are honored for values omitted below:
|
||||
# OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||
# OTEL_EXPORTER_OTLP_HEADERS, OTEL_TRACES_SAMPLER, OTEL_TRACES_SAMPLER_ARG, etc.
|
||||
#
|
||||
# When this is enabled together with sentryForBackend, Misskey shares Sentry's
|
||||
# TracerProvider and adds an OTLP SpanProcessor to it. In that mode:
|
||||
# - sampleRate below is ignored; sampling follows sentryForBackend.options
|
||||
# tracesSampleRate / tracesSampler.
|
||||
# - resourceAttributes below is ignored; set OTEL_SERVICE_NAME and
|
||||
# OTEL_RESOURCE_ATTRIBUTES in the environment if you need to override them.
|
||||
# - spans produced by Sentry integrations are exported to both Sentry and OTLP.
|
||||
# - sentry-trace / baggage propagation to outbound remote requests is disabled
|
||||
# by default while OTel is enabled. Set propagateTraceToRemote: true only if
|
||||
# you intentionally want Sentry trace propagation for your deployment.
|
||||
|
||||
#otelForBackend:
|
||||
# endpoint: 'http://localhost:4318/v1/traces'
|
||||
# #headers:
|
||||
# # authorization: 'Bearer xxxxx'
|
||||
# #sampleRate: 1.0
|
||||
# # Record PostgreSQL query spans. Disabled by default to avoid instrumentation
|
||||
# # overhead when database-level diagnostics are not needed.
|
||||
# #capturePgSpans: false
|
||||
# # Include raw SQL text in PostgreSQL spans. Requires capturePgSpans and is
|
||||
# # disabled by default because non-parameterized queries can expose values to
|
||||
# # the OTLP Collector.
|
||||
# #capturePgStatement: false
|
||||
# # Include PostgreSQL connection-pool spans with an existing parent span.
|
||||
# # Requires capturePgSpans and is disabled by default to avoid noisy spans
|
||||
# # from connection churn.
|
||||
# #capturePgConnectionSpans: false
|
||||
# # Record Redis command spans. Disabled by default because subscribing to the
|
||||
# # ioredis diagnostics channel adds work to every Redis command, including
|
||||
# # commands without a parent span. Enable for development diagnostics or when
|
||||
# # the added overhead is acceptable in production.
|
||||
# #captureRedisCommandSpans: false
|
||||
# # Include Redis startup/reconnect spans. Disabled by default to avoid noisy
|
||||
# # connection churn; these spans are recorded even without an HTTP or job
|
||||
# # parent span.
|
||||
# #captureRedisConnectionSpans: false
|
||||
# # Record Redis commands without an HTTP or job parent span. Disabled by
|
||||
# # default because queue polling and background work can produce many root
|
||||
# # traces; only applies when captureRedisCommandSpans is enabled.
|
||||
# #captureRedisRootSpans: false
|
||||
# #resourceAttributes:
|
||||
# # deployment.environment: 'production'
|
||||
# #propagateTraceToRemote: false
|
||||
# # Queue worker spans are independent traces linked to their enqueuer by default.
|
||||
# # Set to 'parent' to make them child spans instead. This can create very large
|
||||
# # traces for high-fan-out queues such as federation delivery.
|
||||
# #jobTraceContextMode: 'link'
|
||||
|
||||
#sentryForFrontend:
|
||||
# vueIntegration:
|
||||
# tracingOptions:
|
||||
|
||||
@@ -16,8 +16,6 @@ on:
|
||||
- packages/misskey-js/**
|
||||
- packages/misskey-bubble-game/**
|
||||
- packages/misskey-reversi/**
|
||||
- packages/misskey-world/**
|
||||
- packages/frontend-misskey-world-engine/**
|
||||
- packages/shared/eslint.config.js
|
||||
- scripts/check-dts*.mjs
|
||||
- .github/workflows/lint.yml
|
||||
@@ -34,8 +32,6 @@ on:
|
||||
- packages/misskey-js/**
|
||||
- packages/misskey-bubble-game/**
|
||||
- packages/misskey-reversi/**
|
||||
- packages/misskey-world/**
|
||||
- packages/frontend-misskey-world-engine/**
|
||||
- packages/shared/eslint.config.js
|
||||
- scripts/check-dts*.mjs
|
||||
- .github/workflows/lint.yml
|
||||
@@ -73,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 }}
|
||||
|
||||
+11
-2
@@ -48,12 +48,21 @@
|
||||
- Fix: ノートの詳細表示で削除された引用元が表示されない問題を修正
|
||||
|
||||
### Server
|
||||
- Feat: OpenTelemetryサポート
|
||||
- 詳細な設定はconfigファイルを参照してください。
|
||||
- Sentryとの併用も可能です。Sentry併用時は、PostgreSQL Query と Redis command は Sentry で計装されます。
|
||||
- 以下の自動計装をサポートしています。(計装対象にする項目は設定可能)
|
||||
- PostgreSQL query
|
||||
- Redis command
|
||||
- 全ての受信HTTPリクエスト
|
||||
- 全ての送信HTTPリクエスト
|
||||
- ジョブキュー(エンキュー元のトレースを含む)
|
||||
- Feat: ログ基盤の刷新
|
||||
- API内部エラーのログに構造化属性と正規化したエラー情報を付与し、認証情報を自動的に秘匿するように(従来形式の表示は維持)
|
||||
- ログ全体の既定出力レベルとドメインごとの出力レベルを設定できるように
|
||||
- バックエンドのログを1行JSON形式で出力できるように
|
||||
- SentryのTrace ContextをJSON形式のログへ関連付けられるように
|
||||
- HTTPのAccess logをstatus class単位で出力できるように(開発時のリクエスト・レスポンス本文、SentryのTrace Contextにも対応)
|
||||
- OpenTelemetryのTrace ContextをJSON形式のログへ関連付けられるように
|
||||
- HTTPのAccess logをstatus class単位で出力できるように(開発時のリクエスト・レスポンス本文、OpenTelemetryのTrace Contextにも対応)
|
||||
- Enhance: Sentry バックエンドの自動計装を `sentryForBackend.disabledIntegrations` で個別に無効化できるように
|
||||
- Enhance: センシティブメディアの判定を外部サービス ([sensitive-detector](https://github.com/misskey-dev/sensitive-detector)) に分離し、`nsfwjs` / `@tensorflow/tfjs(-node)` の同梱と NSFW 判定モデルを廃止 (#16804)
|
||||
- Enhance: Node.js 22.22.2以降、24.17.0以降、26.4.0以降をサポートするように
|
||||
|
||||
-10
@@ -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
|
||||
|
||||
@@ -1414,8 +1414,6 @@ viewRenotedChannel: "リノート先のチャンネルを見る"
|
||||
previewingTheme: "テーマのプレビュー中"
|
||||
previewingThemeRestore: "元に戻す"
|
||||
accessToken: "アクセストークン"
|
||||
choose: "選択"
|
||||
rotate: "回転"
|
||||
chooseEmojiPalette: "絵文字パレットを選択"
|
||||
addToEmojiPalette: "絵文字パレットに追加"
|
||||
emojiPaletteAlreadyAddedConfirm: "この絵文字はすでにこの絵文字パレットに含まれています。追加しなおしますか?"
|
||||
@@ -3582,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": "喫藍心整"
|
||||
|
||||
@@ -20,8 +20,6 @@
|
||||
"packages/misskey-js/generator",
|
||||
"packages/misskey-reversi",
|
||||
"packages/misskey-bubble-game",
|
||||
"packages/misskey-world",
|
||||
"packages/frontend-misskey-world-engine",
|
||||
"packages-private/*"
|
||||
],
|
||||
"private": true,
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class WorldRoom1778744540138 {
|
||||
name = 'WorldRoom1778744540138'
|
||||
|
||||
/**
|
||||
* @param {QueryRunner} queryRunner
|
||||
*/
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`CREATE TABLE "world_room" ("id" character varying(32) NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "name" character varying(256) NOT NULL, "description" character varying(1024) NOT NULL, "userId" character varying(32) NOT NULL, "likedCount" integer NOT NULL DEFAULT '0', "visibility" character varying(128) NOT NULL DEFAULT 'public', "def" jsonb NOT NULL DEFAULT '{}', CONSTRAINT "PK_40cfacaf35b0b54bb2281c89767" PRIMARY KEY ("id"))`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_88289375952050da4a7752a366" ON "world_room" ("updatedAt") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_f803a5efb4125c5fd8a414285e" ON "world_room" ("userId") `);
|
||||
await queryRunner.query(`ALTER TABLE "world_room" ADD CONSTRAINT "FK_f803a5efb4125c5fd8a414285ed" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {QueryRunner} queryRunner
|
||||
*/
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "world_room" DROP CONSTRAINT "FK_f803a5efb4125c5fd8a414285ed"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_f803a5efb4125c5fd8a414285e"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_88289375952050da4a7752a366"`);
|
||||
await queryRunner.query(`DROP TABLE "world_room"`);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class WorldAvatar1779921322355 {
|
||||
name = 'WorldAvatar1779921322355'
|
||||
|
||||
/**
|
||||
* @param {QueryRunner} queryRunner
|
||||
*/
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`CREATE TABLE "world_avatar" ("id" character varying(32) NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "name" character varying(256) NOT NULL, "userId" character varying(32) NOT NULL, "def" jsonb NOT NULL DEFAULT '{}', "active" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_e7a27262285cc2c27114871f866" PRIMARY KEY ("id"))`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_0f1d0bdfaca455cc2f13defabe" ON "world_avatar" ("updatedAt") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_4eba43c8e2540a92e99dd7f5a9" ON "world_avatar" ("userId") `);
|
||||
await queryRunner.query(`ALTER TABLE "world_room" ADD "accessCount" integer NOT NULL DEFAULT '0'`);
|
||||
await queryRunner.query(`ALTER TABLE "world_avatar" ADD CONSTRAINT "FK_4eba43c8e2540a92e99dd7f5a9a" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {QueryRunner} queryRunner
|
||||
*/
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "world_avatar" DROP CONSTRAINT "FK_4eba43c8e2540a92e99dd7f5a9a"`);
|
||||
await queryRunner.query(`ALTER TABLE "world_room" DROP COLUMN "accessCount"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_4eba43c8e2540a92e99dd7f5a9"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_0f1d0bdfaca455cc2f13defabe"`);
|
||||
await queryRunner.query(`DROP TABLE "world_avatar"`);
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@
|
||||
"@fastify/cors": "11.3.0",
|
||||
"@fastify/http-proxy": "11.5.0",
|
||||
"@fastify/multipart": "10.1.0",
|
||||
"@fastify/otel": "0.20.1",
|
||||
"@fastify/static": "10.1.2",
|
||||
"@kitajs/html": "4.2.13",
|
||||
"@misskey-dev/emoji-assets": "17.0.3",
|
||||
@@ -67,6 +68,15 @@
|
||||
"@nestjs/common": "11.1.28",
|
||||
"@nestjs/core": "11.1.28",
|
||||
"@nestjs/testing": "11.1.28",
|
||||
"@opentelemetry/api": "1.9.1",
|
||||
"@opentelemetry/core": "2.9.0",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "0.220.0",
|
||||
"@opentelemetry/instrumentation": "0.220.0",
|
||||
"@opentelemetry/instrumentation-pg": "0.72.0",
|
||||
"@opentelemetry/resources": "2.9.0",
|
||||
"@opentelemetry/sdk-trace-base": "2.9.0",
|
||||
"@opentelemetry/sdk-trace-node": "2.9.0",
|
||||
"@opentelemetry/semantic-conventions": "1.43.0",
|
||||
"@oxc-project/runtime": "0.139.0",
|
||||
"@peertube/http-signature": "1.7.0",
|
||||
"@sentry/node": "10.65.0",
|
||||
|
||||
@@ -87,6 +87,7 @@ export default defineConfig((args) => {
|
||||
'class-validator',
|
||||
/^@sentry\/.*/,
|
||||
/^@sentry-internal\/.*/,
|
||||
/^@opentelemetry\/.*/,
|
||||
'@nestjs/websockets/socket-module',
|
||||
'@nestjs/microservices/microservices-module',
|
||||
'@nestjs/microservices',
|
||||
|
||||
@@ -27,6 +27,25 @@ type SentryBackendConfig = {
|
||||
disabledIntegrations?: string[];
|
||||
};
|
||||
|
||||
type SentryBackendConfigSource = Omit<SentryBackendConfig, 'options'> & {
|
||||
options?: Partial<Sentry.NodeOptions>;
|
||||
};
|
||||
|
||||
type OtelBackendConfig = {
|
||||
endpoint?: string;
|
||||
headers?: Record<string, string>;
|
||||
sampleRate?: number;
|
||||
capturePgSpans?: boolean;
|
||||
capturePgStatement?: boolean;
|
||||
capturePgConnectionSpans?: boolean;
|
||||
captureRedisCommandSpans?: boolean;
|
||||
captureRedisConnectionSpans?: boolean;
|
||||
captureRedisRootSpans?: boolean;
|
||||
resourceAttributes?: Record<string, string>;
|
||||
propagateTraceToRemote?: boolean;
|
||||
jobTraceContextMode?: 'link' | 'parent';
|
||||
};
|
||||
|
||||
/**
|
||||
* 設定ファイルの型
|
||||
*/
|
||||
@@ -71,7 +90,8 @@ type Source = {
|
||||
index: string;
|
||||
scope?: 'local' | 'global' | string[];
|
||||
};
|
||||
sentryForBackend?: SentryBackendConfig;
|
||||
sentryForBackend?: SentryBackendConfigSource;
|
||||
otelForBackend?: OtelBackendConfig;
|
||||
sentryForFrontend?: {
|
||||
options: Partial<SentryVue.BrowserOptions> & { dsn: string };
|
||||
vueIntegration?: SentryVue.VueIntegrationOptions | null;
|
||||
@@ -217,6 +237,7 @@ export type Config = {
|
||||
redisForTimelines: RedisOptions & RedisOptionsSource;
|
||||
redisForReactions: RedisOptions & RedisOptionsSource;
|
||||
sentryForBackend: SentryBackendConfig | undefined;
|
||||
otelForBackend: OtelBackendConfig | undefined;
|
||||
sentryForFrontend: {
|
||||
options: Partial<SentryVue.BrowserOptions> & { dsn: string };
|
||||
vueIntegration?: SentryVue.VueIntegrationOptions | null;
|
||||
@@ -321,7 +342,8 @@ export function loadConfig(): Config {
|
||||
redisForJobQueue: config.redisForJobQueue ? convertRedisOptions(config.redisForJobQueue, host) : redis,
|
||||
redisForTimelines: config.redisForTimelines ? convertRedisOptions(config.redisForTimelines, host) : redis,
|
||||
redisForReactions: config.redisForReactions ? convertRedisOptions(config.redisForReactions, host) : redis,
|
||||
sentryForBackend: config.sentryForBackend,
|
||||
sentryForBackend: config.sentryForBackend == null ? undefined : normalizeSentryBackendConfig(config.sentryForBackend),
|
||||
otelForBackend: config.otelForBackend,
|
||||
sentryForFrontend: config.sentryForFrontend,
|
||||
id: config.id,
|
||||
proxy: config.proxy,
|
||||
@@ -358,6 +380,13 @@ export function loadConfig(): Config {
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSentryBackendConfig(config: SentryBackendConfigSource): SentryBackendConfig {
|
||||
return {
|
||||
...config,
|
||||
options: config.options ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
function tryCreateUrl(url: string) {
|
||||
try {
|
||||
return new URL(url);
|
||||
|
||||
@@ -154,11 +154,6 @@ import { ApQuestionService } from './activitypub/models/ApQuestionService.js';
|
||||
import { QueueModule } from './QueueModule.js';
|
||||
import { QueueService } from './QueueService.js';
|
||||
import { LoggerService } from './LoggerService.js';
|
||||
import { WorldRoomService } from './WorldRoomService.js';
|
||||
import { WorldRoomEntityService } from './entities/WorldRoomEntityService.js';
|
||||
import { WorldMultiplayService } from './WorldMultiplayService.js';
|
||||
import { WorldAvatarService } from './WorldAvatarService.js';
|
||||
import { WorldAvatarEntityService } from './entities/WorldAvatarEntityService.js';
|
||||
import { TelemetryService } from './telemetry/TelemetryService.js';
|
||||
import type { Provider } from '@nestjs/common';
|
||||
|
||||
@@ -236,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 $WorldMultiplayService: Provider = { provide: 'WorldMultiplayService', useExisting: WorldMultiplayService };
|
||||
const $WorldAvatarService: Provider = { provide: 'WorldAvatarService', useExisting: WorldAvatarService };
|
||||
|
||||
const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService };
|
||||
const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart };
|
||||
@@ -294,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 };
|
||||
@@ -394,9 +384,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
RegistryApiService,
|
||||
ReversiService,
|
||||
PageService,
|
||||
WorldRoomService,
|
||||
WorldMultiplayService,
|
||||
WorldAvatarService,
|
||||
|
||||
ChartLoggerService,
|
||||
FederationChart,
|
||||
@@ -452,8 +439,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
ReversiGameEntityService,
|
||||
MetaEntityService,
|
||||
SystemWebhookEntityService,
|
||||
WorldRoomEntityService,
|
||||
WorldAvatarEntityService,
|
||||
|
||||
ApAudienceService,
|
||||
ApDbResolverService,
|
||||
@@ -550,9 +535,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
$RegistryApiService,
|
||||
$ReversiService,
|
||||
$PageService,
|
||||
$WorldRoomService,
|
||||
$WorldMultiplayService,
|
||||
$WorldAvatarService,
|
||||
|
||||
$ChartLoggerService,
|
||||
$FederationChart,
|
||||
@@ -608,8 +590,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
$ReversiGameEntityService,
|
||||
$MetaEntityService,
|
||||
$SystemWebhookEntityService,
|
||||
$WorldRoomEntityService,
|
||||
$WorldAvatarEntityService,
|
||||
|
||||
$ApAudienceService,
|
||||
$ApDbResolverService,
|
||||
@@ -706,9 +686,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
RegistryApiService,
|
||||
ReversiService,
|
||||
PageService,
|
||||
WorldRoomService,
|
||||
WorldMultiplayService,
|
||||
WorldAvatarService,
|
||||
|
||||
FederationChart,
|
||||
NotesChart,
|
||||
@@ -763,8 +740,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
ReversiGameEntityService,
|
||||
MetaEntityService,
|
||||
SystemWebhookEntityService,
|
||||
WorldRoomEntityService,
|
||||
WorldAvatarEntityService,
|
||||
|
||||
ApAudienceService,
|
||||
ApDbResolverService,
|
||||
@@ -860,9 +835,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
$RegistryApiService,
|
||||
$ReversiService,
|
||||
$PageService,
|
||||
$WorldRoomService,
|
||||
$WorldMultiplayService,
|
||||
$WorldAvatarService,
|
||||
|
||||
$FederationChart,
|
||||
$NotesChart,
|
||||
@@ -917,8 +889,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||
$ReversiGameEntityService,
|
||||
$MetaEntityService,
|
||||
$SystemWebhookEntityService,
|
||||
$WorldRoomEntityService,
|
||||
$WorldAvatarEntityService,
|
||||
|
||||
$ApAudienceService,
|
||||
$ApDbResolverService,
|
||||
|
||||
@@ -173,16 +173,6 @@ export interface ChatEventTypes {
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorldEventTypes {
|
||||
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>;
|
||||
};
|
||||
world: {
|
||||
name: `worldStream:${string}`;
|
||||
payload: EventTypesToEventPayload<WorldEventTypes>;
|
||||
};
|
||||
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 publishWorldStream<K extends keyof WorldEventTypes>(spaceKey: string, type: K, value?: WorldEventTypes[K]): void {
|
||||
this.publish(`worldStream:${spaceKey}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { baseQueueOptions, QUEUE } from '@/queue/const.js';
|
||||
import { allSettled } from '@/misc/promise-tracker.js';
|
||||
import { instrumentQueue } from '@/core/telemetry/queue-instrumentation.js';
|
||||
import {
|
||||
DeliverJobData,
|
||||
EndedPollNotificationJobData,
|
||||
@@ -32,7 +33,12 @@ export type UserWebhookDeliverQueue = Bull.Queue<UserWebhookDeliverJobData>;
|
||||
export type SystemWebhookDeliverQueue = Bull.Queue<SystemWebhookDeliverJobData>;
|
||||
|
||||
function createQueue<T extends object>(queueName: string, config: Config): Bull.Queue<T> {
|
||||
return new Bull.Queue<T>(queueName, baseQueueOptions(config, queueName));
|
||||
const queue = new Bull.Queue<T>(queueName, baseQueueOptions(config, queueName));
|
||||
// Queue のラップは、enqueue 時に OTel context をジョブデータへ埋め込むためのもの。
|
||||
// Sentry 単独ではジョブ間の context 伝播を使わないので、OTel 未設定時は元の Queue を返す。
|
||||
if (config.otelForBackend == null) return queue;
|
||||
|
||||
return instrumentQueue(queue);
|
||||
}
|
||||
|
||||
const $system: Provider = {
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DataSource, In, Not } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import {
|
||||
MiDriveFile,
|
||||
MiWorldAvatar,
|
||||
} from '@/models/_.js';
|
||||
import type { DriveFilesRepository, WorldAvatarsRepository } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
|
||||
@Injectable()
|
||||
export class WorldAvatarService {
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@Inject(DI.worldAvatarsRepository)
|
||||
private worldAvatarsRepository: WorldAvatarsRepository,
|
||||
|
||||
private roleService: RoleService,
|
||||
private moderationLogService: ModerationLogService,
|
||||
private queryService: QueryService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
public defaultAvatar = {
|
||||
type: 'default',
|
||||
body: {
|
||||
color: [0.8, 0.8, 0.8],
|
||||
roughness: 1,
|
||||
metallic: 0,
|
||||
},
|
||||
eyes: {
|
||||
type: 'a',
|
||||
color: [0, 0, 0],
|
||||
},
|
||||
mouth: {
|
||||
type: 'a',
|
||||
color: [0, 0, 0],
|
||||
},
|
||||
accessories: [],
|
||||
} satisfies MiWorldAvatar['def'];
|
||||
|
||||
@bindThis
|
||||
public async validateDef(
|
||||
me: MiUser,
|
||||
def: MiWorldAvatar['def'],
|
||||
): Promise<boolean> {
|
||||
// TODO
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async findMyAvatarById(userId: MiUser['id'], avatarId: MiWorldAvatar['id']) {
|
||||
return this.worldAvatarsRepository.findOneBy({ id: avatarId, userId: userId });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async findAvatarById(avatarId: MiWorldAvatar['id']) {
|
||||
return this.worldAvatarsRepository.findOne({ where: { id: avatarId }, relations: { user: true } });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getMyAvatarsWithPagination(userId: MiUser['id'], limit: number, sinceId?: MiWorldAvatar['id'] | null, untilId?: MiWorldAvatar['id'] | null) {
|
||||
const query = this.queryService.makePaginationQuery(this.worldAvatarsRepository.createQueryBuilder('avatar'), sinceId, untilId)
|
||||
.andWhere('avatar.userId = :userId', { userId });
|
||||
|
||||
const avatars = await query.take(limit).getMany();
|
||||
|
||||
return avatars;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async create(
|
||||
me: MiUser,
|
||||
body: Partial<MiWorldAvatar>,
|
||||
): Promise<MiWorldAvatar> {
|
||||
const currentAvatarsCount = await this.worldAvatarsRepository.countBy({ userId: me.id });
|
||||
|
||||
// TODO: limit by role policy
|
||||
|
||||
const avatar = await this.worldAvatarsRepository.insertOne(new MiWorldAvatar({
|
||||
id: this.idService.gen(),
|
||||
updatedAt: new Date(),
|
||||
name: body.name,
|
||||
def: body.def,
|
||||
userId: me.id,
|
||||
active: currentAvatarsCount === 0,
|
||||
}));
|
||||
|
||||
return avatar;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async update(
|
||||
avatar: MiWorldAvatar,
|
||||
body: Partial<MiWorldAvatar>,
|
||||
): Promise<void> {
|
||||
body.updatedAt = new Date();
|
||||
const updated = await this.worldAvatarsRepository.createQueryBuilder().update()
|
||||
.set(body)
|
||||
.where('id = :id', { id: avatar.id })
|
||||
.returning('*')
|
||||
.execute()
|
||||
.then((response) => {
|
||||
return response.raw[0];
|
||||
});
|
||||
|
||||
if (body.active) {
|
||||
await this.worldAvatarsRepository.createQueryBuilder().update()
|
||||
.set({ active: false })
|
||||
.where('userId = :userId', { userId: avatar.userId })
|
||||
.andWhere('id != :id', { id: avatar.id })
|
||||
.execute();
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async delete(avatar: MiWorldAvatar, deleter?: MiUser): Promise<void> {
|
||||
await this.worldAvatarsRepository.delete(avatar.id);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getActiveAvatarOfUser(userId: MiUser['id']) {
|
||||
return this.worldAvatarsRepository.findOneBy({ userId, active: true });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getActiveAvatarOfUsers(userIds: MiUser['id'][]): Promise<MiWorldAvatar[]> {
|
||||
if (userIds.length === 0) return [];
|
||||
return this.worldAvatarsRepository.findBy({ userId: In(userIds), active: true });
|
||||
}
|
||||
}
|
||||
@@ -1,144 +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 { 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 WorldMultiplayService {
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
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'], spaceKey: string) {
|
||||
console.log('enter', { userId, spaceKey: spaceKey });
|
||||
|
||||
// TODO: atomicにやる
|
||||
const currentPlayers = await this.redisClient.hlen(`world:${spaceKey}:players`);
|
||||
if (currentPlayers < 50) {
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
redisPipeline.hset(`world:${spaceKey}:players`, userId, 1);
|
||||
redisPipeline.hexpire(`world:${spaceKey}:players`, 30, 'FIELDS', 1, userId);
|
||||
await redisPipeline.exec();
|
||||
} else {
|
||||
throw new Error('The group is full.');
|
||||
}
|
||||
|
||||
// TODO: 既に入っていたらスキップ
|
||||
const avatar = await this.worldAvatarService.getActiveAvatarOfUser(userId);
|
||||
this.globalEventService.publishWorldStream(spaceKey, 'enter', {
|
||||
user: await this.userEntityService.pack(userId),
|
||||
avatar: avatar?.def,
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async heartbeat(userId: MiUser['id'], spaceKey: string) {
|
||||
const exists = await this.redisClient.hexists(`world:${spaceKey}:players`, userId);
|
||||
if (exists) {
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
redisPipeline.hexpire(`world:${spaceKey}:players`, 30, 'FIELDS', 1, userId);
|
||||
redisPipeline.hexpire(`world:${spaceKey}:playerStates`, 30, 'FIELDS', 1, userId);
|
||||
await redisPipeline.exec();
|
||||
} else {
|
||||
throw new Error('Not in the group.');
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async left(userId: MiUser['id'], spaceKey: string) {
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
redisPipeline.hdel(`world:${spaceKey}:players`, userId);
|
||||
redisPipeline.hdel(`world:${spaceKey}:playerStates`, userId);
|
||||
await redisPipeline.exec();
|
||||
|
||||
this.globalEventService.publishWorldStream(spaceKey, 'left', {
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async updatePlayerState(userId: MiUser['id'], spaceKey: string, state: PlayerState) {
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
redisPipeline.hset(`world:${spaceKey}:playerStates`, userId, JSON.stringify(state));
|
||||
redisPipeline.hexpire(`world:${spaceKey}:playerStates`, 30, 'FIELDS', 1, userId);
|
||||
await redisPipeline.exec();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getPlayerStates(spaceKey: string): Promise<Record<string, PlayerState>> {
|
||||
const entries = await this.redisClient.hgetall(`world:${spaceKey}:playerStates`);
|
||||
return Object.fromEntries(Object.entries(entries).map(([userId, state]) => [userId, JSON.parse(state) as PlayerState]));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getPlayerStatesAndHeatbeat(userId: MiUser['id'], spaceKey: string): Promise<Record<string, PlayerState>> {
|
||||
// TODO: atomicにやる
|
||||
this.heartbeat(userId, spaceKey);
|
||||
return this.getPlayerStates(spaceKey);
|
||||
}
|
||||
|
||||
@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(spaceKey: string, userId?: MiUser['id']): Promise<Record<string, any>> {
|
||||
let playerIds = await this.redisClient.hkeys(`world:${spaceKey}:players`);
|
||||
playerIds = playerIds.filter(id => id !== userId);
|
||||
|
||||
const packedUsers = await this.userEntityService.packMany(playerIds);
|
||||
const avatars = await this.worldAvatarService.getActiveAvatarOfUsers(playerIds);
|
||||
|
||||
const profiles: Record<string, any> = {};
|
||||
for (const playerId of playerIds) {
|
||||
const packedUser = packedUsers.find(u => u.id === playerId);
|
||||
if (packedUser == null) continue;
|
||||
profiles[playerId] = this.packPlayerProfile(packedUser, avatars.find(a => a.userId === playerId)?.def ?? null);
|
||||
}
|
||||
return profiles;
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DataSource, In, Not } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import {
|
||||
MiDriveFile,
|
||||
MiWorldRoom,
|
||||
} from '@/models/_.js';
|
||||
import type { DriveFilesRepository, WorldRoomsRepository } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
|
||||
const driveFileReferencingOptions = {
|
||||
clippedPicture: ['image'],
|
||||
tapestry: ['image'],
|
||||
poster: ['image'],
|
||||
pictureFrame: ['image'],
|
||||
tabletopPictureFrame: ['image'],
|
||||
tabletopGlassPictureFrame: ['image'],
|
||||
wallCanvas: ['image'],
|
||||
wallGlassPictureFrame: ['image'],
|
||||
tabletopFlag: ['image'],
|
||||
tabletopLcdButtonsController: ['image'],
|
||||
djPlayer: ['image'],
|
||||
monitor: ['image'],
|
||||
allInOnePc: ['image'],
|
||||
laptopPc: ['image'],
|
||||
handheldGameConsole: ['image'],
|
||||
largeMousepad: ['image'],
|
||||
kakejiku: ['image'],
|
||||
} as Record<string, string[]>;
|
||||
|
||||
@Injectable()
|
||||
export class WorldRoomService {
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@Inject(DI.worldRoomsRepository)
|
||||
private worldRoomsRepository: WorldRoomsRepository,
|
||||
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
private roleService: RoleService,
|
||||
private moderationLogService: ModerationLogService,
|
||||
private queryService: QueryService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async validateDef(
|
||||
me: MiUser,
|
||||
def: MiWorldRoom['def'],
|
||||
): Promise<boolean> {
|
||||
// TODO: スキーマ検証(関係ないプロパティを入れたり不正な値を入れたりできないように)
|
||||
// そのためにはJSON SchemaでRoomState/各objectのoptionsを定義する必要がある
|
||||
|
||||
const objectsLimit = 100; // TODO: ref role policy
|
||||
if (def.installedFurnitures.length > objectsLimit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const attachedFilesLimit = 30; // TODO: ref role policy
|
||||
|
||||
const attachedFileIds = this.collectReferencedDriveFileIds(def);
|
||||
if (attachedFileIds.size > attachedFilesLimit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const attachedFiles = attachedFileIds.size === 0 ? [] : await this.driveFilesRepository.findBy({ id: In([...attachedFileIds]), userId: me.id });
|
||||
for (const file of attachedFiles) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
return false;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
return false;
|
||||
}
|
||||
if (Math.max(file.properties.width ?? 0, file.properties.height ?? 0) > 2048) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async findMyRoomById(userId: MiUser['id'], roomId: MiWorldRoom['id']) {
|
||||
return this.worldRoomsRepository.findOneBy({ id: roomId, userId: userId });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async findRoomById(roomId: MiWorldRoom['id']) {
|
||||
return this.worldRoomsRepository.findOne({ where: { id: roomId }, relations: { user: true } });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getRoomsOfUserWithPagination(userId: MiUser['id'], self: boolean, limit: number, sinceId?: MiWorldRoom['id'] | null, untilId?: MiWorldRoom['id'] | null) {
|
||||
const query = this.queryService.makePaginationQuery(this.worldRoomsRepository.createQueryBuilder('room'), sinceId, untilId)
|
||||
.andWhere('room.userId = :userId', { userId });
|
||||
|
||||
if (!self) {
|
||||
query.andWhere('room.visibility = :visibility', { visibility: 'public' });
|
||||
}
|
||||
|
||||
const rooms = await query.take(limit).getMany();
|
||||
|
||||
return rooms;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async create(
|
||||
me: MiUser,
|
||||
body: Partial<MiWorldRoom>,
|
||||
): Promise<MiWorldRoom> {
|
||||
const room = await this.worldRoomsRepository.insertOne(new MiWorldRoom({
|
||||
id: this.idService.gen(),
|
||||
updatedAt: new Date(),
|
||||
name: body.name,
|
||||
description: body.description,
|
||||
def: body.def,
|
||||
userId: me.id,
|
||||
visibility: body.visibility,
|
||||
}));
|
||||
|
||||
return room;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async update(
|
||||
room: MiWorldRoom,
|
||||
body: Partial<MiWorldRoom>,
|
||||
): Promise<void> {
|
||||
body.updatedAt = new Date();
|
||||
return this.worldRoomsRepository.createQueryBuilder().update()
|
||||
.set(body)
|
||||
.where('id = :id', { id: room.id })
|
||||
.returning('*')
|
||||
.execute()
|
||||
.then((response) => {
|
||||
return response.raw[0];
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async delete(room: MiWorldRoom, deleter?: MiUser): Promise<void> {
|
||||
await this.worldRoomsRepository.delete(room.id);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public collectReferencedDriveFileIds(roomState: MiWorldRoom['def']): Set<MiDriveFile['id']> {
|
||||
const fileIds = new Set<MiDriveFile['id']>();
|
||||
const installedFurnitures = roomState.installedFurnitures ?? roomState.installedObjects; // 後方互換性のため
|
||||
for (const o of installedFurnitures) {
|
||||
const def = driveFileReferencingOptions[o.type];
|
||||
if (def == null) continue;
|
||||
for (const key of def) {
|
||||
const optionValue = o.options[key];
|
||||
if (optionValue != null && optionValue.driveFileId != null && optionValue.driveFileId !== '') {
|
||||
fileIds.add(optionValue.driveFileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return fileIds;
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { DriveFilesRepository, MiWorldAvatar, WorldAvatarsRepository } from '@/models/_.js';
|
||||
import { awaitAll } from '@/misc/prelude/await-all.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import type { } from '@/models/Blocking.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type { MiDriveFile } from '@/models/DriveFile.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { WorldAvatarService } from '@/core/WorldAvatarService.js';
|
||||
import { UserEntityService } from './UserEntityService.js';
|
||||
import { DriveFileEntityService } from './DriveFileEntityService.js';
|
||||
import { In } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class WorldAvatarEntityService {
|
||||
constructor(
|
||||
@Inject(DI.worldAvatarsRepository)
|
||||
private worldAvatarsRepository: WorldAvatarsRepository,
|
||||
|
||||
private worldAvatarService: WorldAvatarService,
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packLite(
|
||||
src: MiWorldAvatar['id'] | MiWorldAvatar,
|
||||
me?: { id: MiUser['id'] } | null | undefined,
|
||||
hint?: {
|
||||
packedUser?: Packed<'UserLite'>
|
||||
},
|
||||
): Promise<Packed<'WorldAvatarLite'>> {
|
||||
const meId = me ? me.id : null;
|
||||
const avatar = typeof src === 'object' ? src : await this.worldAvatarsRepository.findOneByOrFail({ id: src });
|
||||
|
||||
return await awaitAll({
|
||||
id: avatar.id,
|
||||
def: avatar.def,
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packDetailed(
|
||||
src: MiWorldAvatar['id'] | MiWorldAvatar,
|
||||
me?: { id: MiUser['id'] } | null | undefined,
|
||||
hint?: {
|
||||
packedUser?: Packed<'UserLite'>
|
||||
},
|
||||
): Promise<Packed<'WorldAvatarDetailed'>> {
|
||||
const meId = me ? me.id : null;
|
||||
const avatar = typeof src === 'object' ? src : await this.worldAvatarsRepository.findOneByOrFail({ id: src });
|
||||
|
||||
return await awaitAll({
|
||||
id: avatar.id,
|
||||
createdAt: this.idService.parse(avatar.id).date.toISOString(),
|
||||
updatedAt: avatar.updatedAt.toISOString(),
|
||||
name: avatar.name,
|
||||
def: avatar.def,
|
||||
active: avatar.active,
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packLiteMany(
|
||||
avatars: MiWorldAvatar[],
|
||||
me?: { id: MiUser['id'] } | null | undefined,
|
||||
) {
|
||||
const _users = avatars.map(({ user, userId }) => user ?? userId);
|
||||
const _userMap = await this.userEntityService.packMany(_users, me)
|
||||
.then(users => new Map(users.map(u => [u.id, u])));
|
||||
return Promise.all(avatars.map(avatar => this.packLite(avatar, me, { packedUser: _userMap.get(avatar.userId) })));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packDetailedMany(
|
||||
avatars: MiWorldAvatar[],
|
||||
me?: { id: MiUser['id'] } | null | undefined,
|
||||
) {
|
||||
const _users = avatars.map(({ user, userId }) => user ?? userId);
|
||||
const _userMap = await this.userEntityService.packMany(_users, me)
|
||||
.then(users => new Map(users.map(u => [u.id, u])));
|
||||
return Promise.all(avatars.map(avatar => this.packDetailed(avatar, me, { packedUser: _userMap.get(avatar.userId) })));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { DriveFilesRepository, MiWorldRoom, WorldRoomsRepository } from '@/models/_.js';
|
||||
import { awaitAll } from '@/misc/prelude/await-all.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import type { } from '@/models/Blocking.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type { MiDriveFile } from '@/models/DriveFile.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { WorldRoomService } from '@/core/WorldRoomService.js';
|
||||
import { UserEntityService } from './UserEntityService.js';
|
||||
import { DriveFileEntityService } from './DriveFileEntityService.js';
|
||||
import { In } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class WorldRoomEntityService {
|
||||
constructor(
|
||||
@Inject(DI.worldRoomsRepository)
|
||||
private worldRoomsRepository: WorldRoomsRepository,
|
||||
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
private worldRoomService: WorldRoomService,
|
||||
private userEntityService: UserEntityService,
|
||||
private driveFileEntityService: DriveFileEntityService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packLite(
|
||||
src: MiWorldRoom['id'] | MiWorldRoom,
|
||||
me?: { id: MiUser['id'] } | null | undefined,
|
||||
hint?: {
|
||||
packedUser?: Packed<'UserLite'>
|
||||
},
|
||||
): Promise<Packed<'WorldRoomLite'>> {
|
||||
const meId = me ? me.id : null;
|
||||
const room = typeof src === 'object' ? src : await this.worldRoomsRepository.findOneByOrFail({ id: src });
|
||||
|
||||
return await awaitAll({
|
||||
id: room.id,
|
||||
createdAt: this.idService.parse(room.id).date.toISOString(),
|
||||
updatedAt: room.updatedAt.toISOString(),
|
||||
userId: room.userId,
|
||||
user: hint?.packedUser ?? this.userEntityService.pack(room.user ?? room.userId, me),
|
||||
name: room.name,
|
||||
description: room.description,
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packDetailed(
|
||||
src: MiWorldRoom['id'] | MiWorldRoom,
|
||||
me?: { id: MiUser['id'] } | null | undefined,
|
||||
hint?: {
|
||||
packedUser?: Packed<'UserLite'>
|
||||
},
|
||||
): Promise<Packed<'WorldRoomDetailed'>> {
|
||||
const meId = me ? me.id : null;
|
||||
const room = typeof src === 'object' ? src : await this.worldRoomsRepository.findOneByOrFail({ id: src });
|
||||
|
||||
const attachedFileIds = this.worldRoomService.collectReferencedDriveFileIds(room.def);
|
||||
const attachedFiles = attachedFileIds.size === 0 ? [] : await this.driveFilesRepository.findBy({ id: In([...attachedFileIds]), userId: room.userId });
|
||||
|
||||
return await awaitAll({
|
||||
id: room.id,
|
||||
createdAt: this.idService.parse(room.id).date.toISOString(),
|
||||
updatedAt: room.updatedAt.toISOString(),
|
||||
userId: room.userId,
|
||||
user: hint?.packedUser ?? this.userEntityService.pack(room.user ?? room.userId, me),
|
||||
name: room.name,
|
||||
description: room.description,
|
||||
def: room.def,
|
||||
attachedFiles: this.driveFileEntityService.packMany(attachedFiles),
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packLiteMany(
|
||||
rooms: MiWorldRoom[],
|
||||
me?: { id: MiUser['id'] } | null | undefined,
|
||||
) {
|
||||
const _users = rooms.map(({ user, userId }) => user ?? userId);
|
||||
const _userMap = await this.userEntityService.packMany(_users, me)
|
||||
.then(users => new Map(users.map(u => [u.id, u])));
|
||||
return Promise.all(rooms.map(room => this.packLite(room, me, { packedUser: _userMap.get(room.userId) })));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { captureMessage, shutdownTelemetry, startSpan } from './telemetry-registry.js';
|
||||
import { captureMessage, shutdownTelemetry, startSpan, startSpanWithTraceContext } from './telemetry-registry.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
import type { TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js';
|
||||
|
||||
@@ -21,6 +21,13 @@ export class TelemetryService implements OnApplicationShutdown {
|
||||
return startSpan(name, fn);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public startSpanWithTraceContext<T>(name: string, jobData: object, fn: () => T): T {
|
||||
// jobData に enqueue 元の context があれば worker span へ復元する。
|
||||
// context の無い既存ジョブは、通常の startSpan と同じ扱いになる。
|
||||
return startSpanWithTraceContext(name, jobData, fn);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async onApplicationShutdown(_signal?: string): Promise<void> {
|
||||
await shutdownTelemetry();
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as os from 'node:os';
|
||||
import cluster from 'node:cluster';
|
||||
import { envOption } from '@/env.js';
|
||||
import { registerDiagLogger } from '@/core/telemetry/telemetry-diag.js';
|
||||
import { installHttpClientInstrumentation } from '@/core/telemetry/http-client-instrumentation.js';
|
||||
import { installDatabaseInstrumentation } from '@/core/telemetry/database-instrumentation.js';
|
||||
import { installRedisInstrumentation } from '@/core/telemetry/redis-instrumentation.js';
|
||||
import { executeSpan, getQueueTraceContextMode, injectActiveTraceContext, recordSpanError, startSpanWithQueueTraceContext } from '@/core/telemetry/queue-trace-context.js';
|
||||
import type { LogTraceContext } from '@/logging/types.js';
|
||||
import type { Span, SpanStatusCode, Tracer } from '@opentelemetry/api';
|
||||
import type { Resource, ResourceDetector } from '@opentelemetry/resources';
|
||||
import type { ParentBasedSampler, Sampler } from '@opentelemetry/sdk-trace-base';
|
||||
import type { OtelBackendRuntimeConfig, TelemetryAdapter, TelemetryCaptureMessageOptions } from './TelemetryAdapter.js';
|
||||
import type { QueueTraceContextCarrier, QueueTraceContextDeps } from '../queue-trace-context.js';
|
||||
|
||||
const DEFAULT_SHUTDOWN_TIMEOUT = 5000;
|
||||
|
||||
type OpenTelemetryAdapterDeps = {
|
||||
tracer: Pick<Tracer, 'startActiveSpan'>;
|
||||
provider: {
|
||||
shutdown(): Promise<void>;
|
||||
};
|
||||
getActiveSpan: () => Span | undefined;
|
||||
spanStatusCodeError: SpanStatusCode;
|
||||
shutdownTimeout: number;
|
||||
shutdownHttpClientInstrumentation?: () => void;
|
||||
shutdownDatabaseInstrumentation?: () => void;
|
||||
shutdownRedisInstrumentation?: () => void;
|
||||
queueTraceContext?: QueueTraceContextDeps;
|
||||
};
|
||||
|
||||
type CreateSamplerDeps = {
|
||||
ParentBasedSampler: new (config: { root: Sampler }) => ParentBasedSampler;
|
||||
TraceIdRatioBasedSampler: new (sampleRate: number) => Sampler;
|
||||
};
|
||||
|
||||
type CreateResourceDeps = {
|
||||
defaultResource: () => Resource;
|
||||
resourceFromAttributes: (attributes: Record<string, string>) => Resource;
|
||||
detectResources: (config: { detectors: ResourceDetector[] }) => Resource;
|
||||
envDetector: ResourceDetector;
|
||||
serviceNameAttribute: string;
|
||||
serviceInstanceIdAttribute: string;
|
||||
serviceVersionAttribute: string;
|
||||
serviceVersion: string;
|
||||
};
|
||||
|
||||
export class OpenTelemetryAdapter implements TelemetryAdapter {
|
||||
public constructor(
|
||||
private readonly deps: OpenTelemetryAdapterDeps,
|
||||
) {
|
||||
}
|
||||
|
||||
public static async create(config: OtelBackendRuntimeConfig): Promise<OpenTelemetryAdapter> {
|
||||
const [
|
||||
{ context, diag, DiagLogLevel, propagation, ROOT_CONTEXT, SpanKind, SpanStatusCode, trace },
|
||||
{ W3CTraceContextPropagator },
|
||||
{ OTLPTraceExporter },
|
||||
{ defaultResource, detectResources, envDetector, resourceFromAttributes },
|
||||
{ BatchSpanProcessor, ParentBasedSampler, TraceIdRatioBasedSampler },
|
||||
{ NodeTracerProvider },
|
||||
{ ATTR_SERVICE_INSTANCE_ID, ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION },
|
||||
] = await Promise.all([
|
||||
import('@opentelemetry/api'),
|
||||
import('@opentelemetry/core'),
|
||||
import('@opentelemetry/exporter-trace-otlp-proto'),
|
||||
import('@opentelemetry/resources'),
|
||||
import('@opentelemetry/sdk-trace-base'),
|
||||
import('@opentelemetry/sdk-trace-node'),
|
||||
import('@opentelemetry/semantic-conventions'),
|
||||
]);
|
||||
|
||||
// OTel SDK内部のexport失敗は既定だと見えにくいため、Misskeyのloggerへ橋渡しする。
|
||||
registerDiagLogger(diag, DiagLogLevel.WARN);
|
||||
|
||||
// endpoint/headersを未指定にしておくと、OTEL_EXPORTER_OTLP_* 環境変数の標準fallbackが効く。
|
||||
const exporter = new OTLPTraceExporter({
|
||||
...(config.endpoint != null ? { url: config.endpoint } : {}),
|
||||
...(config.headers != null ? { headers: config.headers } : {}),
|
||||
});
|
||||
const spanProcessor = new BatchSpanProcessor(exporter);
|
||||
|
||||
// SDK 2.xではSpanProcessorをprovider生成時に渡す。ここでOTel単体用のproviderを作る。
|
||||
const provider = new NodeTracerProvider({
|
||||
resource: createResource(config, {
|
||||
defaultResource,
|
||||
resourceFromAttributes,
|
||||
detectResources,
|
||||
envDetector,
|
||||
serviceNameAttribute: ATTR_SERVICE_NAME,
|
||||
serviceInstanceIdAttribute: ATTR_SERVICE_INSTANCE_ID,
|
||||
serviceVersionAttribute: ATTR_SERVICE_VERSION,
|
||||
serviceVersion: config.serviceVersion,
|
||||
}),
|
||||
...(config.sampleRate != null ? { sampler: createSampler(config.sampleRate, {
|
||||
ParentBasedSampler,
|
||||
TraceIdRatioBasedSampler,
|
||||
}) } : {}),
|
||||
spanProcessors: [spanProcessor],
|
||||
});
|
||||
|
||||
// HTTP送信には注入しないが、将来のQueue連結でpropagation APIを使える状態にする。
|
||||
provider.register({
|
||||
propagator: new W3CTraceContextPropagator(),
|
||||
});
|
||||
|
||||
// provider操作をdepsに閉じ込め、span wrapper本体をユニットテストしやすくする。
|
||||
const tracer = provider.getTracer('misskey-backend');
|
||||
|
||||
return new OpenTelemetryAdapter({
|
||||
tracer,
|
||||
provider,
|
||||
getActiveSpan: () => trace.getActiveSpan(),
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
shutdownTimeout: DEFAULT_SHUTDOWN_TIMEOUT,
|
||||
shutdownHttpClientInstrumentation: installHttpClientInstrumentation({
|
||||
tracer,
|
||||
spanKindClient: SpanKind.CLIENT,
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
}),
|
||||
// pg のrequire hookとioredis diagnostics channelは、Nest moduleの動的importより前に有効化する。
|
||||
shutdownDatabaseInstrumentation: await installDatabaseInstrumentation(provider, {
|
||||
capturePgSpans: config.capturePgSpans === true,
|
||||
capturePgStatement: config.capturePgStatement === true,
|
||||
capturePgConnectionSpans: config.capturePgConnectionSpans === true,
|
||||
}),
|
||||
shutdownRedisInstrumentation: installRedisInstrumentation(tracer, SpanKind.CLIENT, SpanStatusCode.ERROR, {
|
||||
captureConnectionSpans: config.captureRedisConnectionSpans === true,
|
||||
captureCommandSpans: config.captureRedisCommandSpans === true,
|
||||
requireParentSpan: config.captureRedisRootSpans !== true,
|
||||
}),
|
||||
queueTraceContext: {
|
||||
tracer,
|
||||
propagation,
|
||||
trace,
|
||||
getActiveContext: () => context.active(),
|
||||
rootContext: ROOT_CONTEXT,
|
||||
mode: getQueueTraceContextMode(config.jobTraceContextMode),
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public captureMessage(message: string, _opts: TelemetryCaptureMessageOptions): void {
|
||||
// captureMessageは例外通知APIなので、OTelでは対象spanにエラー状態を付ける。
|
||||
// アクティブspanが無い場合(例: BullMQのjob処理が既に完了しspanが閉じた後の'failed'イベント)でも
|
||||
// 通知を握り潰さないよう、報告専用の短命spanを作ってそこに記録する。
|
||||
const span = this.deps.getActiveSpan();
|
||||
if (span != null) {
|
||||
recordSpanError(span, new Error(message), this.deps.spanStatusCodeError);
|
||||
return;
|
||||
}
|
||||
|
||||
this.deps.tracer.startActiveSpan('captureMessage', reportSpan => {
|
||||
recordSpanError(reportSpan, new Error(message), this.deps.spanStatusCodeError);
|
||||
reportSpan.end();
|
||||
});
|
||||
}
|
||||
|
||||
/** activeなSpanの識別子を、Logging基盤で扱える形式へ変換します。 */
|
||||
public getActiveTraceContext(): LogTraceContext | undefined {
|
||||
const activeSpan = this.deps.getActiveSpan();
|
||||
if (activeSpan == null) return undefined;
|
||||
|
||||
const { traceId, spanId, traceFlags } = activeSpan.spanContext();
|
||||
return { traceId, spanId, traceFlags };
|
||||
}
|
||||
|
||||
public startSpan<T>(name: string, fn: () => T): T {
|
||||
// 既存のTelemetryAdapter契約に合わせ、同期/非同期どちらでも同じspan lifetimeを保証する。
|
||||
return this.deps.tracer.startActiveSpan(name, span => executeSpan(span, fn, this.deps.spanStatusCodeError));
|
||||
}
|
||||
|
||||
public injectTraceContext(carrier: QueueTraceContextCarrier): void {
|
||||
const queueTraceContext = this.deps.queueTraceContext;
|
||||
// Queue context 用の依存は任意なので、無い場合はジョブデータを変更しない。
|
||||
if (queueTraceContext == null) return;
|
||||
injectActiveTraceContext(queueTraceContext, carrier);
|
||||
}
|
||||
|
||||
public startSpanWithTraceContext<T>(name: string, jobData: object, fn: () => T): T {
|
||||
const queueTraceContext = this.deps.queueTraceContext;
|
||||
// Queue context 用の依存が無い場合は、従来の span 作成経路と同じ動作を保つ。
|
||||
if (queueTraceContext == null) return this.startSpan(name, fn);
|
||||
|
||||
return startSpanWithQueueTraceContext(queueTraceContext, name, jobData, fn, () => this.startSpan(name, fn));
|
||||
}
|
||||
|
||||
public async shutdown(): Promise<void> {
|
||||
this.deps.shutdownHttpClientInstrumentation?.();
|
||||
this.deps.shutdownDatabaseInstrumentation?.();
|
||||
this.deps.shutdownRedisInstrumentation?.();
|
||||
// BatchSpanProcessorのflushが詰まってもプロセス終了を妨げないよう、上限時間を設ける。
|
||||
// タイムアウト側のtimerは、flushが先に終わった場合にイベントループを無駄に引き留めないようclearする。
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
await Promise.race([
|
||||
this.deps.provider.shutdown(),
|
||||
new Promise<void>(resolve => {
|
||||
timer = setTimeout(resolve, this.deps.shutdownTimeout);
|
||||
}),
|
||||
]).finally(() => {
|
||||
if (timer != null) clearTimeout(timer);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createResource(config: OtelBackendRuntimeConfig, deps: CreateResourceDeps): Resource {
|
||||
// resourceを明示指定するとSDKのdefaultResource()は自動付与されなくなる(マージではなく上書き)ため、
|
||||
// telemetry.sdk.*等の標準属性を失わないよう明示的にmergeする。
|
||||
const misskeyDefaultResource = deps.resourceFromAttributes({
|
||||
[deps.serviceNameAttribute]: 'misskey-backend',
|
||||
[deps.serviceInstanceIdAttribute]: `${os.hostname()}:${process.pid}`,
|
||||
[deps.serviceVersionAttribute]: deps.serviceVersion,
|
||||
'misskey.process.role': getMisskeyProcessRole(),
|
||||
});
|
||||
|
||||
// OTel標準の OTEL_SERVICE_NAME / OTEL_RESOURCE_ATTRIBUTES を尊重する。
|
||||
// mergeは右辺が優先されるため、config.resourceAttributesを最優先にする。
|
||||
return deps.defaultResource()
|
||||
.merge(misskeyDefaultResource)
|
||||
.merge(deps.detectResources({ detectors: [deps.envDetector] }))
|
||||
.merge(deps.resourceFromAttributes(config.resourceAttributes ?? {}));
|
||||
}
|
||||
|
||||
export function createSampler(sampleRate: number, deps: CreateSamplerDeps): ParentBasedSampler {
|
||||
// 設定ミスを無言でAlwaysOn/AlwaysOffに倒さず、起動時に明確に失敗させる。
|
||||
// (YAMLでクォートされた数値文字列などnumber型の保証が無い値が来てもここで弾く)
|
||||
if (typeof sampleRate !== 'number' || !Number.isFinite(sampleRate) || sampleRate < 0 || sampleRate > 1) {
|
||||
throw new Error('otelForBackend.sampleRate must be a number between 0.0 and 1.0.');
|
||||
}
|
||||
|
||||
return new deps.ParentBasedSampler({
|
||||
root: new deps.TraceIdRatioBasedSampler(sampleRate),
|
||||
});
|
||||
}
|
||||
|
||||
export function getMisskeyProcessRole(): string {
|
||||
// Trace backend上でserver/queue/workerを見分けられるよう、Misskey固有の役割をresourceに載せる。
|
||||
if (envOption.disableClustering) {
|
||||
if (envOption.onlyServer) return 'primary-server';
|
||||
if (envOption.onlyQueue) return 'primary-queue';
|
||||
return 'primary-server+queue';
|
||||
}
|
||||
|
||||
if (cluster.isPrimary) {
|
||||
if (envOption.onlyServer) return 'fork-only';
|
||||
if (envOption.onlyQueue) return 'primary-queue';
|
||||
return 'primary-server';
|
||||
}
|
||||
|
||||
// worker.tsのworkerMainに合わせる: onlyServerならserver()、それ以外はjobQueue()を実行する。
|
||||
if (envOption.onlyServer) return 'worker-server';
|
||||
return 'worker-queue';
|
||||
}
|
||||
@@ -3,13 +3,18 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import Logger from '@/logger.js';
|
||||
import { registerDiagLogger } from '@/core/telemetry/telemetry-diag.js';
|
||||
import { getQueueTraceContextMode, injectActiveTraceContext, startSpanWithQueueTraceContext } from '@/core/telemetry/queue-trace-context.js';
|
||||
import type { LogTraceContext } from '@/logging/types.js';
|
||||
import type * as SentryNode from '@sentry/node';
|
||||
import type { NodeOptions } from '@sentry/node';
|
||||
import type { SentryBackendConfig, TelemetryAdapter, TelemetryCaptureMessageOptions } from './TelemetryAdapter.js';
|
||||
import type { OtelBackendRuntimeConfig, SentryBackendConfig, TelemetryAdapter, TelemetryCaptureMessageOptions } from './TelemetryAdapter.js';
|
||||
import type { QueueTraceContextCarrier, QueueTraceContextDeps } from '../queue-trace-context.js';
|
||||
|
||||
// Sentryのtransportが詰まってもプロセス終了を妨げないようにする。
|
||||
// OpenTelemetryAdapterのDEFAULT_SHUTDOWN_TIMEOUTと揃え、Sentryのtransportが詰まってもプロセス終了を妨げないようにする。
|
||||
const DEFAULT_SHUTDOWN_TIMEOUT = 5000;
|
||||
const logger = new Logger('telemetry', 'green');
|
||||
|
||||
type SentryIntegrationsOption = NonNullable<NodeOptions['integrations']>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -68,9 +73,50 @@ export function buildSentryNodeOptions(
|
||||
};
|
||||
}
|
||||
|
||||
type BuildSentryOtlpInitOptions = {
|
||||
sentryConfig: SentryBackendConfig;
|
||||
otelConfig: OtelBackendRuntimeConfig;
|
||||
otlpProcessor: unknown;
|
||||
nodeProfilingIntegration?: () => SentryIntegration;
|
||||
warn?: (message: string) => void;
|
||||
};
|
||||
|
||||
export function buildSentryOtlpInitOptions(options: BuildSentryOtlpInitOptions): SentryNodeOptions {
|
||||
// OTel併存時も、remoteへtrace headerを漏らさないデフォルトはSentry単体時と揃える。
|
||||
// propagateTraceToRemote: true か、options.tracePropagationTargets の明示指定がある場合のみ既定を上書きする。
|
||||
const { tracePropagationTargets, ...sentryOptions } = options.sentryConfig.options;
|
||||
const propagateTraceToRemote = options.otelConfig.propagateTraceToRemote === true || tracePropagationTargets != null;
|
||||
const warn = options.warn ?? ((message: string) => logger.warn(message));
|
||||
|
||||
if (options.otelConfig.sampleRate != null) {
|
||||
warn('otelForBackend.sampleRate is ignored when sentryForBackend is also configured; configure sentryForBackend.options.tracesSampleRate or tracesSampler instead.');
|
||||
}
|
||||
|
||||
if (options.otelConfig.resourceAttributes != null) {
|
||||
warn('otelForBackend.resourceAttributes is ignored when sentryForBackend is also configured; configure OTEL_RESOURCE_ATTRIBUTES instead.');
|
||||
}
|
||||
|
||||
return {
|
||||
...buildSentryNodeOptions({
|
||||
...options.sentryConfig,
|
||||
options: {
|
||||
...sentryOptions,
|
||||
...(propagateTraceToRemote ? { tracePropagationTargets } : {}),
|
||||
},
|
||||
}, options.nodeProfilingIntegration),
|
||||
|
||||
// Sentryの単一TracerProviderにOTLP processorを追加し、親欠損や二重providerを避ける。
|
||||
openTelemetrySpanProcessors: [
|
||||
...(options.sentryConfig.options.openTelemetrySpanProcessors ?? []),
|
||||
options.otlpProcessor as NonNullable<SentryNodeOptions['openTelemetrySpanProcessors']>[number],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export class SentryTelemetryAdapter implements TelemetryAdapter {
|
||||
private constructor(
|
||||
private readonly Sentry: typeof SentryNode,
|
||||
private readonly queueTraceContext?: QueueTraceContextDeps,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -83,6 +129,45 @@ export class SentryTelemetryAdapter implements TelemetryAdapter {
|
||||
return new SentryTelemetryAdapter(Sentry);
|
||||
}
|
||||
|
||||
public static async createWithOtlpExport(
|
||||
sentryConfig: SentryBackendConfig,
|
||||
otelConfig: OtelBackendRuntimeConfig,
|
||||
): Promise<SentryTelemetryAdapter> {
|
||||
const Sentry = await import('@sentry/node');
|
||||
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
|
||||
const { context, diag, DiagLogLevel, propagation, ROOT_CONTEXT, SpanStatusCode, trace } = await import('@opentelemetry/api');
|
||||
const { BatchSpanProcessor } = await import('@opentelemetry/sdk-trace-base');
|
||||
const { OTLPTraceExporter } = await import('@opentelemetry/exporter-trace-otlp-proto');
|
||||
|
||||
registerDiagLogger(diag, DiagLogLevel.WARN);
|
||||
|
||||
// OTLP送信だけを担うprocessorを作り、provider生成はSentry.init側に任せる。
|
||||
const otlpProcessor = new BatchSpanProcessor(new OTLPTraceExporter({
|
||||
...(otelConfig.endpoint != null ? { url: otelConfig.endpoint } : {}),
|
||||
...(otelConfig.headers != null ? { headers: otelConfig.headers } : {}),
|
||||
}));
|
||||
|
||||
// SentryとOTLPを同一providerに集約することで、どちらの宛先にも同じspan実体を流す。
|
||||
Sentry.init(buildSentryOtlpInitOptions({
|
||||
sentryConfig,
|
||||
otelConfig,
|
||||
otlpProcessor,
|
||||
nodeProfilingIntegration,
|
||||
}));
|
||||
|
||||
// Sentry が初期化した同じ OTel provider から tracer/context API を受け取り、
|
||||
// Queue を跨ぐ context 伝播も Sentry と OTLP の両方へ同一 span として出力する。
|
||||
return new SentryTelemetryAdapter(Sentry, {
|
||||
tracer: trace.getTracer('misskey-backend'),
|
||||
propagation,
|
||||
trace,
|
||||
getActiveContext: () => context.active(),
|
||||
rootContext: ROOT_CONTEXT,
|
||||
mode: getQueueTraceContextMode(otelConfig.jobTraceContextMode),
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
});
|
||||
}
|
||||
|
||||
public captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void {
|
||||
this.Sentry.captureMessage(message, {
|
||||
level: opts.level,
|
||||
@@ -104,6 +189,19 @@ export class SentryTelemetryAdapter implements TelemetryAdapter {
|
||||
return this.Sentry.startSpan({ name }, fn);
|
||||
}
|
||||
|
||||
public injectTraceContext(carrier: QueueTraceContextCarrier): void {
|
||||
// Sentry 単体構成では queueTraceContext を持たず、従来どおりジョブデータを変更しない。
|
||||
if (this.queueTraceContext == null) return;
|
||||
injectActiveTraceContext(this.queueTraceContext, carrier);
|
||||
}
|
||||
|
||||
public startSpanWithTraceContext<T>(name: string, jobData: object, fn: () => T): T {
|
||||
// Sentry 単体構成では Sentry 既存の span 作成経路を使う。
|
||||
if (this.queueTraceContext == null) return this.startSpan(name, fn);
|
||||
|
||||
return startSpanWithQueueTraceContext(this.queueTraceContext, name, jobData, fn, () => this.startSpan(name, fn));
|
||||
}
|
||||
|
||||
public async shutdown(): Promise<void> {
|
||||
// timeout未指定だとtransportのflushが詰まった際にプロセス終了を妨げるため、上限時間を設ける。
|
||||
await this.Sentry.close(DEFAULT_SHUTDOWN_TIMEOUT);
|
||||
|
||||
@@ -5,14 +5,19 @@
|
||||
|
||||
import type { Config } from '@/config.js';
|
||||
import type { LogTraceContext } from '@/logging/types.js';
|
||||
import type { QueueTraceContextCarrier } from '../queue-trace-context.js';
|
||||
|
||||
export type SentryBackendConfig = NonNullable<Config['sentryForBackend']>;
|
||||
export type OtelBackendConfig = NonNullable<Config['otelForBackend']>;
|
||||
export type OtelBackendRuntimeConfig = OtelBackendConfig & {
|
||||
serviceVersion: string;
|
||||
};
|
||||
|
||||
export interface TelemetryCaptureMessageOptions {
|
||||
/** 現在はエラー通知用途だけに絞る。追加する場合は各adapterでの扱いを揃えること。 */
|
||||
level: 'error';
|
||||
|
||||
/** Sentryではuser.idへ渡す補助情報です。 */
|
||||
/** Sentryではuser.idへ渡す。OTel adapterは現在span属性へ付与していないため、必要ならadapter側で拡張する。 */
|
||||
userId?: string;
|
||||
|
||||
/** queue名やendpoint名など、通知先で調査に使う補助情報。 */
|
||||
@@ -20,14 +25,14 @@ export interface TelemetryCaptureMessageOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* エラートラッキング/APMサービスごとの実装差異を隠蔽するための抽象。
|
||||
* Sentry・OpenTelemetryなど、エラートラッキング/APMサービスごとの実装差異を隠蔽するための抽象。
|
||||
* 新しいサービスを追加する場合はこのインターフェースを実装するアダプタをこのディレクトリに追加し、
|
||||
* telemetry-registry.tsのinitTelemetry内で登録する。
|
||||
*/
|
||||
export interface TelemetryAdapter {
|
||||
/**
|
||||
* 実行中の処理で起きたエラー相当の事象を記録する。
|
||||
* Sentryではmessage通知として扱う。
|
||||
* Sentryはmessage通知、OTelはactive spanまたは短命spanへの例外記録として扱う。
|
||||
*/
|
||||
captureMessage(message: string, opts: TelemetryCaptureMessageOptions): void;
|
||||
|
||||
@@ -40,6 +45,18 @@ export interface TelemetryAdapter {
|
||||
*/
|
||||
startSpan<T>(name: string, fn: () => T): T;
|
||||
|
||||
/**
|
||||
* BullMQ のジョブデータへ保存する carrier に、active trace context を注入する。
|
||||
* OTel を使わない adapter は実装しない。
|
||||
*/
|
||||
injectTraceContext?(carrier: QueueTraceContextCarrier): void;
|
||||
|
||||
/**
|
||||
* ジョブに保存された enqueue 元の context を、worker span の Link または parent として復元する。
|
||||
* context を持たないジョブの互換性は adapter 側で保つ。
|
||||
*/
|
||||
startSpanWithTraceContext?<T>(name: string, jobData: object, fn: () => T): T;
|
||||
|
||||
/**
|
||||
* プロセス終了時にtelemetry backendへ残りのデータをflushする。
|
||||
* 実装側ではtransport停止に引きずられないよう、待機時間に上限を設ける。
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Span, TracerProvider } from '@opentelemetry/api';
|
||||
|
||||
type Instrumentation = {
|
||||
setTracerProvider(provider: TracerProvider): void;
|
||||
enable(): void;
|
||||
disable(): void;
|
||||
};
|
||||
|
||||
type PgInstrumentationConfig = {
|
||||
enhancedDatabaseReporting: boolean;
|
||||
requireParentSpan: boolean;
|
||||
ignoreConnectSpans: boolean;
|
||||
requestHook?(span: Span): void;
|
||||
};
|
||||
|
||||
type InstrumentationConstructor = new (config: PgInstrumentationConfig) => Instrumentation;
|
||||
|
||||
type InstrumentationDeps = {
|
||||
PgInstrumentation: InstrumentationConstructor;
|
||||
};
|
||||
|
||||
type DatabaseInstrumentationOptions = {
|
||||
capturePgStatement: boolean;
|
||||
capturePgConnectionSpans: boolean;
|
||||
};
|
||||
|
||||
type InstallDatabaseInstrumentationOptions = DatabaseInstrumentationOptions & {
|
||||
capturePgSpans: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* pg はアプリケーションが import する前に有効化しないと、require hook 型の
|
||||
* 自動計装がモジュールを patch できない。そのため、
|
||||
* telemetry provider の登録直後にこの関数を呼び出す。
|
||||
*/
|
||||
export async function installDatabaseInstrumentation(provider: TracerProvider, options: InstallDatabaseInstrumentationOptions): Promise<() => void> {
|
||||
if (!options.capturePgSpans) return () => {};
|
||||
|
||||
const { PgInstrumentation } = await import('@opentelemetry/instrumentation-pg');
|
||||
|
||||
return installInstrumentation(provider, { PgInstrumentation }, options);
|
||||
}
|
||||
|
||||
export function installInstrumentation(provider: TracerProvider, deps: InstrumentationDeps, options: DatabaseInstrumentationOptions = {
|
||||
capturePgStatement: false,
|
||||
capturePgConnectionSpans: false,
|
||||
}): () => void {
|
||||
const instrumentations = [
|
||||
new deps.PgInstrumentation({
|
||||
// SQLパラメータには投稿内容・認証情報などが含まれ得るため、常に記録しない。
|
||||
enhancedDatabaseReporting: false,
|
||||
requireParentSpan: true,
|
||||
ignoreConnectSpans: !options.capturePgConnectionSpans,
|
||||
// instrumentation-pgはSQL本文を無加工で属性へ追加する。明示opt-in時だけ残す。
|
||||
...(options.capturePgStatement ? {} : {
|
||||
requestHook: (span: Span) => {
|
||||
span.setAttribute('db.statement', '[REDACTED]');
|
||||
span.setAttribute('db.query.text', '[REDACTED]');
|
||||
},
|
||||
}),
|
||||
}),
|
||||
];
|
||||
|
||||
try {
|
||||
for (const instrumentation of instrumentations) {
|
||||
instrumentation.setTracerProvider(provider);
|
||||
instrumentation.enable();
|
||||
}
|
||||
} catch (error) {
|
||||
for (const instrumentation of instrumentations) {
|
||||
instrumentation.disable();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const instrumentation of instrumentations) {
|
||||
instrumentation.disable();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { channel } from 'node:diagnostics_channel';
|
||||
import { diag } from '@opentelemetry/api';
|
||||
import type { ClientRequest, IncomingMessage } from 'node:http';
|
||||
import type { Span, SpanOptions, SpanStatusCode, Tracer } from '@opentelemetry/api';
|
||||
|
||||
const HTTP_CLIENT_REQUEST_CREATED = 'http.client.request.created';
|
||||
const HTTP_CLIENT_RESPONSE_FINISH = 'http.client.response.finish';
|
||||
const HTTP_CLIENT_REQUEST_ERROR = 'http.client.request.error';
|
||||
|
||||
type HttpClientSpan = Pick<Span, 'end' | 'recordException' | 'setAttribute' | 'setStatus'>;
|
||||
|
||||
type HttpClientInstrumentationDeps = {
|
||||
tracer: Pick<Tracer, 'startSpan'>;
|
||||
spanKindClient: SpanOptions['kind'];
|
||||
spanStatusCodeError: SpanStatusCode;
|
||||
subscribe: (name: string, listener: (message: unknown) => void) => () => void;
|
||||
reportError?: (error: unknown) => void;
|
||||
};
|
||||
|
||||
type RequestCreatedMessage = { request: ClientRequest };
|
||||
type ResponseFinishMessage = { request: ClientRequest; response: IncomingMessage };
|
||||
type RequestErrorMessage = { request: ClientRequest; error: Error };
|
||||
|
||||
/**
|
||||
* require フックを使わず、Node.js 組み込み HTTP クライアントの diagnostics channel を計装する。
|
||||
* telemetry 初期化前に読み込まれたモジュールも対象になる。
|
||||
*/
|
||||
export function createHttpClientInstrumentation(deps: HttpClientInstrumentationDeps): () => void {
|
||||
const spans = new WeakMap<ClientRequest, HttpClientSpan>();
|
||||
|
||||
const unsubscribeCreated = deps.subscribe(HTTP_CLIENT_REQUEST_CREATED, guardDiagnosticsListener(deps, (message: unknown) => {
|
||||
const { request } = message as RequestCreatedMessage;
|
||||
const { url, host, port } = getRequestDetails(request);
|
||||
const method = request.method;
|
||||
const span = deps.tracer.startSpan(method, {
|
||||
kind: deps.spanKindClient,
|
||||
attributes: {
|
||||
'http.request.method': method,
|
||||
'url.full': url,
|
||||
'server.address': host,
|
||||
'server.port': port,
|
||||
},
|
||||
});
|
||||
spans.set(request, span);
|
||||
}));
|
||||
|
||||
const unsubscribeResponseFinish = deps.subscribe(HTTP_CLIENT_RESPONSE_FINISH, guardDiagnosticsListener(deps, (message: unknown) => {
|
||||
const { request, response } = message as ResponseFinishMessage;
|
||||
const span = spans.get(request);
|
||||
if (span == null) return;
|
||||
|
||||
const statusCode = response.statusCode;
|
||||
if (statusCode != null) {
|
||||
span.setAttribute('http.response.status_code', statusCode);
|
||||
}
|
||||
span.setAttribute('network.protocol.version', response.httpVersion);
|
||||
if (statusCode != null && statusCode >= 400) {
|
||||
span.setAttribute('error.type', String(statusCode));
|
||||
span.setStatus({ code: deps.spanStatusCodeError });
|
||||
}
|
||||
span.end();
|
||||
spans.delete(request);
|
||||
}));
|
||||
|
||||
const unsubscribeRequestError = deps.subscribe(HTTP_CLIENT_REQUEST_ERROR, guardDiagnosticsListener(deps, (message: unknown) => {
|
||||
const { request, error } = message as RequestErrorMessage;
|
||||
const span = spans.get(request);
|
||||
if (span == null) return;
|
||||
|
||||
span.recordException(error);
|
||||
span.setAttribute('error.type', getErrorType(error));
|
||||
span.setStatus({ code: deps.spanStatusCodeError });
|
||||
span.end();
|
||||
spans.delete(request);
|
||||
}));
|
||||
|
||||
return () => {
|
||||
unsubscribeCreated();
|
||||
unsubscribeResponseFinish();
|
||||
unsubscribeRequestError();
|
||||
};
|
||||
}
|
||||
|
||||
export function installHttpClientInstrumentation(deps: Omit<HttpClientInstrumentationDeps, 'subscribe'>): () => void {
|
||||
return createHttpClientInstrumentation({
|
||||
...deps,
|
||||
reportError: deps.reportError ?? (error => diag.error('Failed to process HTTP client diagnostics event.', error)),
|
||||
subscribe: (name, listener) => {
|
||||
const diagnosticChannel = channel(name);
|
||||
diagnosticChannel.subscribe(listener);
|
||||
return () => diagnosticChannel.unsubscribe(listener);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getRequestDetails(request: ClientRequest): { url: string; host: string; port: number } {
|
||||
let protocol: 'http:' | 'https:' = 'http:';
|
||||
|
||||
try {
|
||||
protocol = request.protocol === 'https:' ? 'https:' : 'http:';
|
||||
const host = request.getHeader('host')?.toString() ?? request.host;
|
||||
const url = new URL(request.path || '/', `${protocol}//${host}`);
|
||||
// URL 属性には認証情報やクエリ文字列を含めない。
|
||||
url.username = '';
|
||||
url.password = '';
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
|
||||
return {
|
||||
url: url.toString(),
|
||||
host: url.hostname,
|
||||
// URL.port は既定ポートでは空文字列になるため、スキームから補う。
|
||||
port: url.port === '' ? (url.protocol === 'https:' ? 443 : 80) : Number(url.port),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
url: `${protocol}//localhost/`,
|
||||
host: 'localhost',
|
||||
port: protocol === 'https:' ? 443 : 80,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function guardDiagnosticsListener(
|
||||
deps: Pick<HttpClientInstrumentationDeps, 'reportError'>,
|
||||
listener: (message: unknown) => void,
|
||||
): (message: unknown) => void {
|
||||
return (message) => {
|
||||
try {
|
||||
listener(message);
|
||||
} catch (error) {
|
||||
try {
|
||||
deps.reportError?.(error);
|
||||
} catch {
|
||||
// diagnostics listener の失敗をアプリケーションへ伝播させない。
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getErrorType(error: Error): string {
|
||||
// Node.js の system error code は安定した低カーディナリティの識別子になる。
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
return code ?? error.name;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { injectTraceContext } from './telemetry-registry.js';
|
||||
import { injectQueueTraceContext } from './queue-trace-context.js';
|
||||
import type * as Bull from 'bullmq';
|
||||
|
||||
/**
|
||||
* Queue の add/addBulk をラップし、全ての BullMQ enqueue 経路を一箇所で捕捉する。
|
||||
* QueueService を通さず直接 add/addBulk する呼び出し元もあるため、それぞれで注入すると漏れやすい。
|
||||
*/
|
||||
export function instrumentQueue<T extends object>(queue: Bull.Queue<T>): Bull.Queue<T> {
|
||||
// BullMQ のメソッドは Queue インスタンスを this として使うため、差し替え前に bind して保持する。
|
||||
const add = queue.add.bind(queue);
|
||||
queue.add = ((name, data, opts) => {
|
||||
// BullMQ が data を Redis 用にシリアライズする前に、enqueue 元の context を内部フィールドへ追加する。
|
||||
injectQueueTraceContext(data, injectTraceContext);
|
||||
return add(name, data, opts);
|
||||
}) as typeof queue.add;
|
||||
|
||||
// addBulk は複数ジョブを一度にシリアライズするので、各 data へ同じ context を注入する。
|
||||
const addBulk = queue.addBulk.bind(queue);
|
||||
queue.addBulk = ((jobs) => {
|
||||
for (const job of jobs) {
|
||||
injectQueueTraceContext(job.data, injectTraceContext);
|
||||
}
|
||||
return addBulk(jobs);
|
||||
}) as typeof queue.addBulk;
|
||||
|
||||
return queue;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Context, PropagationAPI, Span, SpanContext, SpanOptions, SpanStatusCode, Tracer } from '@opentelemetry/api';
|
||||
|
||||
/*
|
||||
* enqueue (push) から worker による取得・処理 (pop) までをテレメトリ上で関連付け、
|
||||
* Queue を挟む非同期処理を一連の流れとして追跡できるようにする。
|
||||
* そのため、trace context を次の流れで伝播する:
|
||||
*
|
||||
* 1. producer 側で active context を W3C Trace Context 形式の carrier へ注入する。
|
||||
* 2. carrier をジョブデータの内部フィールドに保存し、BullMQ/Redis 経由で worker へ渡す。
|
||||
* 3. worker 側で carrier を context へ戻し、設定に応じて Link または parent に使う。
|
||||
*
|
||||
* OTel API への依存を引数にしているのは、OTel 単体構成と Sentry + OTLP 構成で
|
||||
* 同じ伝播処理を使うため。
|
||||
*/
|
||||
/** Redis 上のジョブデータにだけ保存する、OpenTelemetry propagator 用の carrier。 */
|
||||
export type QueueTraceContextCarrier = Record<string, string>;
|
||||
export type QueueTraceContextMode = 'link' | 'parent';
|
||||
|
||||
// 通常のジョブプロセッサが参照しない Misskey 内部フィールドとして、ユーザー定義の data と区別する。
|
||||
const QUEUE_TRACE_CONTEXT_KEY = '__misskeyTraceContext';
|
||||
|
||||
export type QueueTraceContextDeps = {
|
||||
tracer: Pick<Tracer, 'startActiveSpan'>;
|
||||
propagation: Pick<PropagationAPI, 'extract' | 'inject'>;
|
||||
trace: {
|
||||
getSpanContext(context: Context): SpanContext | undefined;
|
||||
};
|
||||
getActiveContext: () => Context;
|
||||
rootContext: Context;
|
||||
mode: QueueTraceContextMode;
|
||||
spanStatusCodeError: SpanStatusCode;
|
||||
};
|
||||
|
||||
export type QueueSpanContext = {
|
||||
options: SpanOptions;
|
||||
parentContext: Context;
|
||||
};
|
||||
|
||||
type QueueSpanContextDeps = Pick<QueueTraceContextDeps, 'propagation' | 'trace' | 'rootContext' | 'mode'>;
|
||||
|
||||
/**
|
||||
* enqueue 元の active context を、ジョブ本来のデータを壊さない内部フィールドとして保持する。
|
||||
* propagator が何も注入しなかった場合は、Redis に不要な空オブジェクトを残さない。
|
||||
*/
|
||||
export function injectQueueTraceContext(data: unknown, inject: (carrier: QueueTraceContextCarrier) => void): void {
|
||||
// BullMQ の型定義外から渡される値も考慮し、書き込めない値は無視する。
|
||||
if (data == null || typeof data !== 'object') return;
|
||||
|
||||
const carrier: QueueTraceContextCarrier = {};
|
||||
inject(carrier);
|
||||
|
||||
// active span が無い場合、propagator は何も注入しない。空の内部フィールドは Redis に保存しない。
|
||||
if (Object.keys(carrier).length === 0) return;
|
||||
Object.assign(data, { [QUEUE_TRACE_CONTEXT_KEY]: carrier });
|
||||
}
|
||||
|
||||
/** 現在実行中の span を propagator の標準形式で carrier へ書き出す。 */
|
||||
export function injectActiveTraceContext(deps: QueueTraceContextDeps, carrier: QueueTraceContextCarrier): void {
|
||||
deps.propagation.inject(deps.getActiveContext(), carrier);
|
||||
}
|
||||
|
||||
/**
|
||||
* ジョブに保存された carrier から、worker span の開始に必要な context と options を組み立てる。
|
||||
*
|
||||
* - parent: enqueue span の子として同じ trace を継続する。
|
||||
* - link: worker span を別の root trace にし、enqueue span への関連だけを Link に残す。
|
||||
*
|
||||
* link モードで trace を分けることで、worker 側の sampling 判定を enqueue 側から独立させられる。
|
||||
*/
|
||||
export function getQueueSpanContext(data: unknown, deps: QueueSpanContextDeps): QueueSpanContext | undefined {
|
||||
const carrier = getQueueTraceContextCarrier(data);
|
||||
if (carrier == null) return undefined;
|
||||
|
||||
// Redis から復元した carrier は別プロセス由来なので、現在の context ではなく ROOT_CONTEXT から展開する。
|
||||
const extractedContext = deps.propagation.extract(deps.rootContext, carrier);
|
||||
if (deps.mode === 'parent') {
|
||||
return {
|
||||
options: {},
|
||||
parentContext: extractedContext,
|
||||
};
|
||||
}
|
||||
|
||||
// Link が受け取るのは Context ではなく SpanContext なので、extract 後に取り出す。
|
||||
const spanContext = deps.trace.getSpanContext(extractedContext);
|
||||
return {
|
||||
options: {
|
||||
root: true,
|
||||
...(spanContext != null ? { links: [{ context: spanContext }] } : {}),
|
||||
},
|
||||
parentContext: deps.rootContext,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* context を持つジョブは Link/parent の規則で span を開始する。
|
||||
* デプロイ前に enqueue されたジョブなど、context を持たない場合は既存の adapter 固有実装へ委ねる。
|
||||
*/
|
||||
export function startSpanWithQueueTraceContext<T>(
|
||||
deps: QueueTraceContextDeps,
|
||||
name: string,
|
||||
jobData: object,
|
||||
fn: () => T,
|
||||
fallback: () => T,
|
||||
): T {
|
||||
const spanContext = getQueueSpanContext(jobData, deps);
|
||||
if (spanContext == null) return fallback();
|
||||
|
||||
return deps.tracer.startActiveSpan(name, spanContext.options, spanContext.parentContext, span => executeSpan(span, fn, deps.spanStatusCodeError));
|
||||
}
|
||||
|
||||
/**
|
||||
* 既存の TelemetryAdapter 契約に合わせ、同期値と Promise のどちらを返す処理も span で包む。
|
||||
* 成功・失敗のどちらでも処理完了まで span を開き、必ず一度だけ閉じる。
|
||||
*/
|
||||
export function executeSpan<T>(span: Span, fn: () => T, spanStatusCodeError: SpanStatusCode): T {
|
||||
try {
|
||||
const result = fn();
|
||||
if (isPromiseLike(result)) {
|
||||
// fn() の戻り値は T のまま保ちつつ、Promise の settle 時に span を閉じる。
|
||||
return result.then(
|
||||
value => {
|
||||
span.end();
|
||||
return value;
|
||||
},
|
||||
error => {
|
||||
recordSpanError(span, error, spanStatusCodeError);
|
||||
span.end();
|
||||
throw error;
|
||||
},
|
||||
) as T;
|
||||
}
|
||||
|
||||
span.end();
|
||||
return result;
|
||||
} catch (error) {
|
||||
// fn() が同期的に throw した場合も、Promise の reject と同じ形で記録して呼び出し元へ戻す。
|
||||
recordSpanError(span, error, spanStatusCodeError);
|
||||
span.end();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Error 以外の throw 値も OTel exporter が扱える例外に正規化し、span をエラー状態にする。 */
|
||||
export function recordSpanError(span: Span, error: unknown, spanStatusCodeError: SpanStatusCode): void {
|
||||
const exception = error instanceof Error ? error : new Error(String(error));
|
||||
span.recordException(exception);
|
||||
span.setStatus({
|
||||
code: spanStatusCodeError,
|
||||
message: exception.message,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 未設定時は、enqueue と worker の sampling を分離できる link を使う。
|
||||
* 設定ミスを無言でフォールバックさせないため、未知の値は起動時にエラーにする。
|
||||
*/
|
||||
export function getQueueTraceContextMode(mode: unknown): QueueTraceContextMode {
|
||||
if (mode == null || mode === 'link') return 'link';
|
||||
if (mode === 'parent') return 'parent';
|
||||
throw new Error('otelForBackend.jobTraceContextMode must be either \'link\' or \'parent\'.');
|
||||
}
|
||||
|
||||
function getQueueTraceContextCarrier(data: unknown): QueueTraceContextCarrier | undefined {
|
||||
if (data == null || typeof data !== 'object') return undefined;
|
||||
const carrier = (data as Record<string, unknown>)[QUEUE_TRACE_CONTEXT_KEY];
|
||||
if (carrier == null || typeof carrier !== 'object' || Array.isArray(carrier)) return undefined;
|
||||
|
||||
// Redis 上の旧いジョブや壊れたデータで worker を落とさないよう、propagator に渡せる string map だけを受け入れる。
|
||||
const entries = Object.entries(carrier);
|
||||
if (entries.length === 0 || entries.some(([, value]) => typeof value !== 'string')) return undefined;
|
||||
return Object.fromEntries(entries) as QueueTraceContextCarrier;
|
||||
}
|
||||
|
||||
function isPromiseLike<T>(value: T): value is T & PromiseLike<Awaited<T>> {
|
||||
// native Promise に限らず thenable も span の完了を待てるよう、instanceof ではなく then の有無で判定する。
|
||||
return value != null && typeof (value as { then?: unknown }).then === 'function';
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { tracingChannel } from 'node:diagnostics_channel';
|
||||
import { context, diag, trace } from '@opentelemetry/api';
|
||||
import type { Span, SpanKind, SpanStatusCode, Tracer } from '@opentelemetry/api';
|
||||
|
||||
type IORedisCommandContext = {
|
||||
command: string;
|
||||
args: string[];
|
||||
database: number | undefined;
|
||||
serverAddress: string;
|
||||
serverPort: number | undefined;
|
||||
};
|
||||
|
||||
type IORedisConnectContext = {
|
||||
serverAddress: string;
|
||||
serverPort: number | undefined;
|
||||
};
|
||||
|
||||
type TracingChannelSubscribers<T extends object> = {
|
||||
start(message: T): void;
|
||||
end(message: T & { error?: unknown }): void;
|
||||
asyncStart(message: T & { error?: unknown }): void;
|
||||
asyncEnd(message: T & { error?: unknown }): void;
|
||||
error(message: T & { error: unknown }): void;
|
||||
};
|
||||
|
||||
type TracingChannel<T extends object> = {
|
||||
subscribe(subscribers: TracingChannelSubscribers<T>): void;
|
||||
unsubscribe(subscribers: TracingChannelSubscribers<T>): void;
|
||||
};
|
||||
|
||||
type RedisInstrumentationDeps = {
|
||||
tracingChannel<T extends object>(name: string): TracingChannel<T>;
|
||||
tracer: Pick<Tracer, 'startSpan'>;
|
||||
getActiveSpan(): Span | undefined;
|
||||
spanKindClient: SpanKind;
|
||||
spanStatusCodeError: SpanStatusCode;
|
||||
reportError?: (error: unknown) => void;
|
||||
};
|
||||
|
||||
type RedisInstrumentationOptions = {
|
||||
captureCommandSpans?: boolean;
|
||||
/** requireParentSpan is the official ioredis instrumentation's default. */
|
||||
requireParentSpan?: boolean;
|
||||
captureConnectionSpans?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* ioredis 5.11以降が公開する native diagnostics channel を購読する。
|
||||
* バンドル後もioredis自身が発行するイベントを使うため、require hook に
|
||||
* 依存せず、ESM import 経路でもコマンド span を取得できる。
|
||||
*/
|
||||
export function installRedisInstrumentation(
|
||||
tracer: Pick<Tracer, 'startSpan'>,
|
||||
spanKindClient: SpanKind,
|
||||
spanStatusCodeError: SpanStatusCode,
|
||||
options: RedisInstrumentationOptions = {},
|
||||
): () => void {
|
||||
return createRedisInstrumentation({
|
||||
tracingChannel,
|
||||
tracer,
|
||||
getActiveSpan: () => trace.getSpan(context.active()),
|
||||
spanKindClient,
|
||||
spanStatusCodeError,
|
||||
reportError: error => diag.error('Failed to process Redis diagnostics event.', error),
|
||||
}, options);
|
||||
}
|
||||
|
||||
export function createRedisInstrumentation(deps: RedisInstrumentationDeps, options: RedisInstrumentationOptions = {}): () => void {
|
||||
const requireParentSpan = options.requireParentSpan ?? true;
|
||||
const cleanup: Array<() => void> = [];
|
||||
if (options.captureCommandSpans === true) {
|
||||
const commandChannel = deps.tracingChannel<IORedisCommandContext>('ioredis:command');
|
||||
const commandSubscribers = createTracingChannelSubscribers(commandChannel, deps, requireParentSpan, message => ({
|
||||
name: message.command,
|
||||
attributes: {
|
||||
'db.system.name': 'redis',
|
||||
'db.operation.name': message.command,
|
||||
'server.address': message.serverAddress,
|
||||
...(message.database != null ? { 'db.namespace': message.database.toString(10) } : {}),
|
||||
...(message.serverPort != null ? { 'server.port': message.serverPort } : {}),
|
||||
},
|
||||
}));
|
||||
cleanup.push(() => commandChannel.unsubscribe(commandSubscribers));
|
||||
}
|
||||
if (options.captureConnectionSpans === true) {
|
||||
const connectChannel = deps.tracingChannel<IORedisConnectContext>('ioredis:connect');
|
||||
// Connection spans are explicitly opt-in and should include startup and reconnect attempts.
|
||||
const connectSubscribers = createTracingChannelSubscribers(connectChannel, deps, false, message => ({
|
||||
name: 'connect',
|
||||
attributes: {
|
||||
'db.system.name': 'redis',
|
||||
'db.operation.name': 'connect',
|
||||
'server.address': message.serverAddress,
|
||||
...(message.serverPort != null ? { 'server.port': message.serverPort } : {}),
|
||||
},
|
||||
}));
|
||||
cleanup.push(() => connectChannel.unsubscribe(connectSubscribers));
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const unsubscribe of cleanup) {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createTracingChannelSubscribers<T extends object>(
|
||||
channel: TracingChannel<T>,
|
||||
deps: RedisInstrumentationDeps,
|
||||
requireParentSpan: boolean,
|
||||
getSpanOptions: (message: T) => { name: string; attributes: Record<string, string | number> },
|
||||
): TracingChannelSubscribers<T> {
|
||||
const spans = new WeakMap<object, { span: Span; recordedError: boolean }>();
|
||||
|
||||
const recordError = (state: { span: Span; recordedError: boolean }, value: unknown): void => {
|
||||
if (state.recordedError) return;
|
||||
const error = toError(value);
|
||||
state.span.recordException(error);
|
||||
state.span.setStatus({ code: deps.spanStatusCodeError, message: error.message });
|
||||
state.span.setAttribute('error.type', getErrorType(error));
|
||||
const statusCode = getRedisErrorStatusCode(error.message);
|
||||
if (statusCode != null) state.span.setAttribute('db.response.status_code', statusCode);
|
||||
state.recordedError = true;
|
||||
};
|
||||
|
||||
const finish = (message: T & { error?: unknown }): void => {
|
||||
const state = spans.get(message);
|
||||
if (state == null) return;
|
||||
|
||||
if (message.error != null) {
|
||||
recordError(state, message.error);
|
||||
}
|
||||
state.span.end();
|
||||
spans.delete(message);
|
||||
};
|
||||
|
||||
const subscribers: TracingChannelSubscribers<T> = {
|
||||
start: guardTracingSubscriber(deps, (message: T) => {
|
||||
if (requireParentSpan && deps.getActiveSpan() == null) return;
|
||||
|
||||
const options = getSpanOptions(message);
|
||||
const span = deps.tracer.startSpan(options.name, {
|
||||
kind: deps.spanKindClient,
|
||||
attributes: options.attributes,
|
||||
});
|
||||
spans.set(message, { span, recordedError: false });
|
||||
}),
|
||||
// Promiseを返すコマンドでは完了前にもendイベントが来るため、同期例外だけここで閉じる。
|
||||
end: guardTracingSubscriber(deps, (message: T & { error?: unknown }) => {
|
||||
if (message.error != null) finish(message);
|
||||
}),
|
||||
asyncStart: () => {},
|
||||
asyncEnd: guardTracingSubscriber(deps, finish),
|
||||
error: guardTracingSubscriber(deps, (message: T & { error: unknown }) => {
|
||||
const state = spans.get(message);
|
||||
if (state == null) return;
|
||||
recordError(state, message.error);
|
||||
}),
|
||||
};
|
||||
|
||||
channel.subscribe(subscribers);
|
||||
return subscribers;
|
||||
}
|
||||
|
||||
function guardTracingSubscriber<T>(
|
||||
deps: Pick<RedisInstrumentationDeps, 'reportError'>,
|
||||
subscriber: (message: T) => void,
|
||||
): (message: T) => void {
|
||||
return (message) => {
|
||||
try {
|
||||
subscriber(message);
|
||||
} catch (error) {
|
||||
try {
|
||||
deps.reportError?.(error);
|
||||
} catch {
|
||||
// diagnostics subscriber の失敗をアプリケーションへ伝播させない。
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function toError(value: unknown): Error {
|
||||
return value instanceof Error ? value : new Error(String(value));
|
||||
}
|
||||
|
||||
function getRedisErrorStatusCode(message: string): string | undefined {
|
||||
return message.match(/^([A-Z][A-Z0-9_]*)\b/)?.[1];
|
||||
}
|
||||
|
||||
function getErrorType(error: Error): string {
|
||||
return (error as NodeJS.ErrnoException).code ?? error.name;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import Logger from '@/logger.js';
|
||||
import type { DiagAPI, DiagLogger, DiagLogLevel } from '@opentelemetry/api';
|
||||
|
||||
export function registerDiagLogger(
|
||||
diagApi: DiagAPI,
|
||||
diagLogLevelWarn: DiagLogLevel,
|
||||
): void {
|
||||
// diagはプロセスグローバルなので、通常運用で必要なWARN以上だけをMisskeyのログに流す。
|
||||
const logger = new Logger('otel', 'green');
|
||||
const diagLogger: DiagLogger = {
|
||||
error: (message, ...args) => logger.error(formatDiagMessage(message, args)),
|
||||
warn: (message, ...args) => logger.warn(formatDiagMessage(message, args)),
|
||||
info: (message, ...args) => logger.info(formatDiagMessage(message, args)),
|
||||
debug: (message, ...args) => logger.debug(formatDiagMessage(message, args)),
|
||||
verbose: (message, ...args) => logger.debug(formatDiagMessage(message, args)),
|
||||
};
|
||||
|
||||
diagApi.setLogger(diagLogger, {
|
||||
logLevel: diagLogLevelWarn,
|
||||
suppressOverrideMessage: true,
|
||||
});
|
||||
}
|
||||
|
||||
function formatDiagMessage(message: string, args: unknown[]): string {
|
||||
if (args.length === 0) return message;
|
||||
return `${message} ${args.map(arg => {
|
||||
if (arg instanceof Error) return arg.stack ?? arg.message;
|
||||
if (typeof arg === 'string') return arg;
|
||||
try {
|
||||
return JSON.stringify(arg);
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
}).join(' ')}`;
|
||||
}
|
||||
@@ -5,8 +5,10 @@
|
||||
|
||||
import type { Config } from '@/config.js';
|
||||
import { setLogTraceContextProvider } from '@/logging/logging-runtime.js';
|
||||
import { OpenTelemetryAdapter } from './adapters/OpenTelemetryAdapter.js';
|
||||
import { SentryTelemetryAdapter } from './adapters/SentryTelemetryAdapter.js';
|
||||
import type { TelemetryAdapter, TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js';
|
||||
import type { OtelBackendRuntimeConfig, TelemetryAdapter, TelemetryCaptureMessageOptions } from './adapters/TelemetryAdapter.js';
|
||||
import type { QueueTraceContextCarrier } from './queue-trace-context.js';
|
||||
|
||||
/**
|
||||
* NestのDIコンテナが構築される前(boot処理内)で初期化する必要があるため、
|
||||
@@ -16,8 +18,24 @@ import type { TelemetryAdapter, TelemetryCaptureMessageOptions } from './adapter
|
||||
const adapters: TelemetryAdapter[] = [];
|
||||
|
||||
export async function initTelemetry(config: Config): Promise<void> {
|
||||
if (config.sentryForBackend) {
|
||||
const adapter = await SentryTelemetryAdapter.create(config.sentryForBackend);
|
||||
const otelForBackend: OtelBackendRuntimeConfig | undefined = config.otelForBackend == null ? undefined : {
|
||||
...config.otelForBackend,
|
||||
serviceVersion: config.version,
|
||||
};
|
||||
|
||||
// SentryとOTelを同時に使う場合はproviderを分けず、Sentry側へOTLP processorを追加する。
|
||||
let adapter: TelemetryAdapter | undefined;
|
||||
if (config.sentryForBackend && otelForBackend) {
|
||||
adapter = await SentryTelemetryAdapter.createWithOtlpExport(config.sentryForBackend, otelForBackend);
|
||||
} else if (config.sentryForBackend) {
|
||||
// Sentry単体時は既存のSentry adapterだけを登録する。
|
||||
adapter = await SentryTelemetryAdapter.create(config.sentryForBackend);
|
||||
} else if (otelForBackend) {
|
||||
// OTel単体時だけMisskey自身でNodeTracerProviderを立てる。
|
||||
adapter = await OpenTelemetryAdapter.create(otelForBackend);
|
||||
}
|
||||
|
||||
if (adapter != null) {
|
||||
adapters.push(adapter);
|
||||
// Telemetryの初期化後に登録し、初期化前のBootstrapログは従来どおり出力する。
|
||||
setLogTraceContextProvider(() => adapter.getActiveTraceContext?.());
|
||||
@@ -45,6 +63,22 @@ export function startSpan<T>(name: string, fn: () => T): T {
|
||||
return wrapped();
|
||||
}
|
||||
|
||||
export function injectTraceContext(carrier: QueueTraceContextCarrier): void {
|
||||
// Queue の carrier は共有データなので、通知と異なり全 adapter にブロードキャストしない。
|
||||
// OTel provider は現在 1 つだけなので、同じ header を上書きしないよう最初の対応 adapter だけを使う。
|
||||
adapters.find(adapter => adapter.injectTraceContext != null)?.injectTraceContext?.(carrier);
|
||||
}
|
||||
|
||||
export function startSpanWithTraceContext<T>(name: string, jobData: object, fn: () => T): T {
|
||||
// Queue context を解釈できる adapter に span 作成を任せる。
|
||||
// 対応 adapter が無い構成では通常の startSpan へフォールバックする。
|
||||
const adapter = adapters.find(adapter => adapter.startSpanWithTraceContext != null);
|
||||
if (adapter?.startSpanWithTraceContext != null) {
|
||||
return adapter.startSpanWithTraceContext(name, jobData, fn);
|
||||
}
|
||||
return startSpan(name, fn);
|
||||
}
|
||||
|
||||
export async function shutdownTelemetry(): Promise<void> {
|
||||
// 終了時は登録済みadapterを並列にflush/shutdownする。
|
||||
await Promise.allSettled(adapters.map(adapter => adapter.shutdown()));
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* Telemetry-specific shutdown is implemented by telemetry-registry.ts.
|
||||
* Signal coordination belongs to boot/shutdown-handler.ts so telemetry and
|
||||
* logging remain independent domains.
|
||||
*/
|
||||
export { shutdownTelemetry } from './telemetry-registry.js';
|
||||
@@ -91,7 +91,5 @@ export const DI = {
|
||||
bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
|
||||
reversiGamesRepository: Symbol('reversiGamesRepository'),
|
||||
noteDraftsRepository: Symbol('noteDraftsRepository'),
|
||||
worldRoomsRepository: Symbol('worldRoomsRepository'),
|
||||
worldAvatarsRepository: Symbol('worldAvatarsRepository'),
|
||||
//#endregion
|
||||
};
|
||||
|
||||
@@ -75,8 +75,6 @@ import { packedChatRoomInvitationSchema } from '@/models/json-schema/chat-room-i
|
||||
import { packedChatRoomMembershipSchema } from '@/models/json-schema/chat-room-membership.js';
|
||||
import { packedAchievementNameSchema, packedAchievementSchema } from '@/models/json-schema/achievement.js';
|
||||
import { packedNoteDraftSchema } from '@/models/json-schema/note-draft.js';
|
||||
import { packedWorldRoomDetailedSchema, packedWorldRoomLiteSchema } from '@/models/json-schema/world-room.js';
|
||||
import { packedWorldAvatarDetailedSchema, packedWorldAvatarLiteSchema } from '@/models/json-schema/world-avatar.js';
|
||||
|
||||
export const refs = {
|
||||
UserLite: packedUserLiteSchema,
|
||||
@@ -149,10 +147,6 @@ export const refs = {
|
||||
ChatRoom: packedChatRoomSchema,
|
||||
ChatRoomInvitation: packedChatRoomInvitationSchema,
|
||||
ChatRoomMembership: packedChatRoomMembershipSchema,
|
||||
WorldRoomLite: packedWorldRoomLiteSchema,
|
||||
WorldRoomDetailed: packedWorldRoomDetailedSchema,
|
||||
WorldAvatarLite: packedWorldAvatarLiteSchema,
|
||||
WorldAvatarDetailed: packedWorldAvatarDetailedSchema,
|
||||
};
|
||||
|
||||
export type Packed<x extends keyof typeof refs> = SchemaType<typeof refs[x]>;
|
||||
|
||||
@@ -84,8 +84,6 @@ import {
|
||||
MiChatRoomMembership,
|
||||
MiChatRoomInvitation,
|
||||
MiChatApproval,
|
||||
MiWorldRoom,
|
||||
MiWorldAvatar,
|
||||
} from './_.js';
|
||||
import type { Provider } from '@nestjs/common';
|
||||
import type { DataSource } from 'typeorm';
|
||||
@@ -546,18 +544,6 @@ const $reversiGamesRepository: Provider = {
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $worldRoomsRepository: Provider = {
|
||||
provide: DI.worldRoomsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(MiWorldRoom).extend(miRepository as MiRepository<MiWorldRoom>),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $worldAvatarsRepository: Provider = {
|
||||
provide: DI.worldAvatarsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(MiWorldAvatar).extend(miRepository as MiRepository<MiWorldAvatar>),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
providers: [
|
||||
@@ -637,8 +623,6 @@ const $worldAvatarsRepository: Provider = {
|
||||
$chatApprovalsRepository,
|
||||
$bubbleGameRecordsRepository,
|
||||
$reversiGamesRepository,
|
||||
$worldRoomsRepository,
|
||||
$worldAvatarsRepository,
|
||||
],
|
||||
exports: [
|
||||
$usersRepository,
|
||||
@@ -717,8 +701,6 @@ const $worldAvatarsRepository: Provider = {
|
||||
$chatApprovalsRepository,
|
||||
$bubbleGameRecordsRepository,
|
||||
$reversiGamesRepository,
|
||||
$worldRoomsRepository,
|
||||
$worldAvatarsRepository,
|
||||
],
|
||||
})
|
||||
export class RepositoryModule {
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm';
|
||||
import { id } from './util/id.js';
|
||||
import { MiUser } from './User.js';
|
||||
|
||||
@Entity('world_avatar')
|
||||
export class MiWorldAvatar {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
})
|
||||
public updatedAt: Date;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 256,
|
||||
})
|
||||
public name: string;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
})
|
||||
public userId: MiUser['id'];
|
||||
|
||||
@ManyToOne(() => MiUser, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
public user: MiUser | null;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public active: boolean;
|
||||
|
||||
@Column('jsonb', {
|
||||
default: {},
|
||||
})
|
||||
public def: Record<string, any>;
|
||||
|
||||
constructor(data: Partial<MiWorldAvatar>) {
|
||||
if (data == null) return;
|
||||
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
(this as any)[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm';
|
||||
import { id } from './util/id.js';
|
||||
import { MiUser } from './User.js';
|
||||
|
||||
export const worldRoomVisibility = ['public', 'private'] as const;
|
||||
export type WorldRoomVisibility = typeof worldRoomVisibility[number];
|
||||
|
||||
@Entity('world_room')
|
||||
export class MiWorldRoom {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
})
|
||||
public updatedAt: Date;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 256,
|
||||
})
|
||||
public name: string;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024,
|
||||
})
|
||||
public description: string;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
})
|
||||
public userId: MiUser['id'];
|
||||
|
||||
@ManyToOne(() => MiUser, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
public user: MiUser | null;
|
||||
|
||||
@Column('integer', {
|
||||
default: 0,
|
||||
})
|
||||
public likedCount: number;
|
||||
|
||||
@Column('integer', {
|
||||
default: 0,
|
||||
})
|
||||
public accessCount: number;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 128, default: 'public',
|
||||
})
|
||||
public visibility: WorldRoomVisibility;
|
||||
|
||||
@Column('jsonb', {
|
||||
default: {},
|
||||
})
|
||||
public def: Record<string, any>;
|
||||
|
||||
constructor(data: Partial<MiWorldRoom>) {
|
||||
if (data == null) return;
|
||||
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
(this as any)[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
|
||||
import { MiChannel } from '@/models/Channel.js';
|
||||
import { MiChannelFavorite } from '@/models/ChannelFavorite.js';
|
||||
import { MiChannelFollowing } from '@/models/ChannelFollowing.js';
|
||||
import { MiChannelMuting } from '@/models/ChannelMuting.js';
|
||||
import { MiChannelMuting } from "@/models/ChannelMuting.js";
|
||||
import { MiChatApproval } from '@/models/ChatApproval.js';
|
||||
import { MiChatMessage } from '@/models/ChatMessage.js';
|
||||
import { MiChatRoom } from '@/models/ChatRoom.js';
|
||||
@@ -84,8 +84,6 @@ import { MiUserProfile } from '@/models/UserProfile.js';
|
||||
import { MiUserPublickey } from '@/models/UserPublickey.js';
|
||||
import { MiUserSecurityKey } from '@/models/UserSecurityKey.js';
|
||||
import { MiWebhook } from '@/models/Webhook.js';
|
||||
import { MiWorldRoom } from '@/models/WorldRoom.js';
|
||||
import { MiWorldAvatar } from '@/models/WorldAvatar.js';
|
||||
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js';
|
||||
|
||||
export interface MiRepository<T extends ObjectLiteral> {
|
||||
@@ -175,8 +173,6 @@ export {
|
||||
MiChatApproval,
|
||||
MiBubbleGameRecord,
|
||||
MiReversiGame,
|
||||
MiWorldRoom,
|
||||
MiWorldAvatar,
|
||||
};
|
||||
|
||||
export type AbuseUserReportsRepository = Repository<MiAbuseUserReport> & MiRepository<MiAbuseUserReport>;
|
||||
@@ -257,5 +253,3 @@ export type ChatRoomInvitationsRepository = Repository<MiChatRoomInvitation> & M
|
||||
export type ChatApprovalsRepository = Repository<MiChatApproval> & MiRepository<MiChatApproval>;
|
||||
export type BubbleGameRecordsRepository = Repository<MiBubbleGameRecord> & MiRepository<MiBubbleGameRecord>;
|
||||
export type ReversiGamesRepository = Repository<MiReversiGame> & MiRepository<MiReversiGame>;
|
||||
export type WorldRoomsRepository = Repository<MiWorldRoom> & MiRepository<MiWorldRoom>;
|
||||
export type WorldAvatarsRepository = Repository<MiWorldAvatar> & MiRepository<MiWorldAvatar>;
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export const packedWorldAvatarLiteSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'id',
|
||||
},
|
||||
def: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const packedWorldAvatarDetailedSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'id',
|
||||
},
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'date-time',
|
||||
},
|
||||
updatedAt: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'date-time',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
def: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
active: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -1,95 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export const packedWorldRoomLiteSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'id',
|
||||
},
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'date-time',
|
||||
},
|
||||
updatedAt: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'date-time',
|
||||
},
|
||||
userId: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'id',
|
||||
},
|
||||
user: {
|
||||
type: 'object',
|
||||
ref: 'UserLite',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const packedWorldRoomDetailedSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'id',
|
||||
},
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'date-time',
|
||||
},
|
||||
updatedAt: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'date-time',
|
||||
},
|
||||
userId: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'id',
|
||||
},
|
||||
user: {
|
||||
type: 'object',
|
||||
ref: 'UserLite',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
def: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
attachedFiles: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'DriveFile',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -87,8 +87,6 @@ import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
|
||||
import { MiReversiGame } from '@/models/ReversiGame.js';
|
||||
import { MiChatApproval } from '@/models/ChatApproval.js';
|
||||
import { MiSystemAccount } from '@/models/SystemAccount.js';
|
||||
import { MiWorldRoom } from '@/models/WorldRoom.js';
|
||||
import { MiWorldAvatar } from '@/models/WorldAvatar.js';
|
||||
|
||||
pg.types.setTypeParser(20, Number);
|
||||
|
||||
@@ -256,8 +254,6 @@ export const entities = [
|
||||
MiChatApproval,
|
||||
MiBubbleGameRecord,
|
||||
MiReversiGame,
|
||||
MiWorldRoom,
|
||||
MiWorldAvatar,
|
||||
...charts,
|
||||
];
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ 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 { runQueueJob } from './queue-job-runner.js';
|
||||
import { runQueueJobWithTraceContext } from './queue-job-runner.js';
|
||||
import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js';
|
||||
import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js';
|
||||
import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
|
||||
@@ -159,7 +159,8 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
};
|
||||
}
|
||||
|
||||
// 以下の各 Worker はジョブの実処理全体を worker span で囲む。
|
||||
// 以下の各 Worker は job.data に保存された enqueue 元の trace context を復元し、
|
||||
// ジョブの実処理全体を Link または parent の worker span で囲む。
|
||||
//#region system
|
||||
{
|
||||
const processer = (job: Bull.Job) => {
|
||||
@@ -179,9 +180,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
const logger = this.logger.createSubLogger('system');
|
||||
|
||||
this.systemQueueWorker = new Bull.Worker(QUEUE.SYSTEM, (job) => {
|
||||
return runQueueJob(
|
||||
return runQueueJobWithTraceContext(
|
||||
this.telemetryService,
|
||||
'Queue: System: ' + job.name,
|
||||
job.data,
|
||||
() => processer(job) as Promise<void>,
|
||||
err => {
|
||||
logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) });
|
||||
@@ -233,9 +235,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
const logger = this.logger.createSubLogger('db');
|
||||
|
||||
this.dbQueueWorker = new Bull.Worker(QUEUE.DB, (job) => {
|
||||
return runQueueJob(
|
||||
return runQueueJobWithTraceContext(
|
||||
this.telemetryService,
|
||||
'Queue: DB: ' + job.name,
|
||||
job.data,
|
||||
() => processer(job),
|
||||
err => {
|
||||
logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) });
|
||||
@@ -263,9 +266,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
const logger = this.logger.createSubLogger('deliver');
|
||||
|
||||
this.deliverQueueWorker = new Bull.Worker(QUEUE.DELIVER, (job) => {
|
||||
return runQueueJob(
|
||||
return runQueueJobWithTraceContext(
|
||||
this.telemetryService,
|
||||
'Queue: Deliver',
|
||||
job.data,
|
||||
() => this.deliverProcessorService.process(job),
|
||||
err => {
|
||||
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job.data.to}`, { e: renderError(err) });
|
||||
@@ -301,9 +305,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
const logger = this.logger.createSubLogger('inbox');
|
||||
|
||||
this.inboxQueueWorker = new Bull.Worker(QUEUE.INBOX, (job) => {
|
||||
return runQueueJob(
|
||||
return runQueueJobWithTraceContext(
|
||||
this.telemetryService,
|
||||
'Queue: Inbox',
|
||||
job.data,
|
||||
() => this.inboxProcessorService.process(job),
|
||||
err => {
|
||||
const activityId = job.data.activity ? job.data.activity.id : 'none';
|
||||
@@ -340,9 +345,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
const logger = this.logger.createSubLogger('user-webhook');
|
||||
|
||||
this.userWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.USER_WEBHOOK_DELIVER, (job) => {
|
||||
return runQueueJob(
|
||||
return runQueueJobWithTraceContext(
|
||||
this.telemetryService,
|
||||
'Queue: UserWebhookDeliver',
|
||||
job.data,
|
||||
() => this.userWebhookDeliverProcessorService.process(job),
|
||||
err => {
|
||||
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job.data.to}`, { e: renderError(err) });
|
||||
@@ -378,9 +384,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
const logger = this.logger.createSubLogger('system-webhook');
|
||||
|
||||
this.systemWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.SYSTEM_WEBHOOK_DELIVER, (job) => {
|
||||
return runQueueJob(
|
||||
return runQueueJobWithTraceContext(
|
||||
this.telemetryService,
|
||||
'Queue: SystemWebhookDeliver',
|
||||
job.data,
|
||||
() => this.systemWebhookDeliverProcessorService.process(job),
|
||||
err => {
|
||||
logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job.data.to}`, { e: renderError(err) });
|
||||
@@ -425,9 +432,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
const logger = this.logger.createSubLogger('relationship');
|
||||
|
||||
this.relationshipQueueWorker = new Bull.Worker(QUEUE.RELATIONSHIP, (job) => {
|
||||
return runQueueJob(
|
||||
return runQueueJobWithTraceContext(
|
||||
this.telemetryService,
|
||||
'Queue: Relationship: ' + job.name,
|
||||
job.data,
|
||||
() => processer(job),
|
||||
err => {
|
||||
logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) });
|
||||
@@ -467,9 +475,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
const logger = this.logger.createSubLogger('objectStorage');
|
||||
|
||||
this.objectStorageQueueWorker = new Bull.Worker(QUEUE.OBJECT_STORAGE, (job) => {
|
||||
return runQueueJob(
|
||||
return runQueueJobWithTraceContext(
|
||||
this.telemetryService,
|
||||
'Queue: ObjectStorage: ' + job.name,
|
||||
job.data,
|
||||
() => processer(job) as Promise<void>,
|
||||
err => {
|
||||
logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) });
|
||||
@@ -498,9 +507,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
const logger = this.logger.createSubLogger('ended-poll-notification');
|
||||
|
||||
this.endedPollNotificationQueueWorker = new Bull.Worker(QUEUE.ENDED_POLL_NOTIFICATION, (job) => {
|
||||
return runQueueJob(
|
||||
return runQueueJobWithTraceContext(
|
||||
this.telemetryService,
|
||||
'Queue: EndedPollNotification',
|
||||
job.data,
|
||||
() => this.endedPollNotificationProcessorService.process(job),
|
||||
err => {
|
||||
logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) });
|
||||
@@ -522,9 +532,10 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
||||
const logger = this.logger.createSubLogger('post-scheduled-note');
|
||||
|
||||
this.postScheduledNoteQueueWorker = new Bull.Worker(QUEUE.POST_SCHEDULED_NOTE, (job) => {
|
||||
return runQueueJob(
|
||||
return runQueueJobWithTraceContext(
|
||||
this.telemetryService,
|
||||
'Queue: PostScheduledNote',
|
||||
job.data,
|
||||
() => this.postScheduledNoteProcessorService.process(job),
|
||||
err => {
|
||||
logger.error(`failed(${err.name}: ${err.message}) id=${job.id}`, { job: renderJob(job), e: renderError(err) });
|
||||
|
||||
@@ -5,16 +5,17 @@
|
||||
|
||||
import type { TelemetryService } from '@/core/telemetry/TelemetryService.js';
|
||||
|
||||
type QueueTelemetryService = Pick<TelemetryService, 'startSpan'>;
|
||||
type QueueTelemetryService = Pick<TelemetryService, 'startSpanWithTraceContext'>;
|
||||
|
||||
/** Queueのprocessorを実行し、失敗処理をSpan内で行います。 */
|
||||
export function runQueueJob<T>(
|
||||
/** QueueのprocessorをTrace Context付きで実行し、失敗処理をSpan内で行います。 */
|
||||
export function runQueueJobWithTraceContext<T>(
|
||||
telemetryService: QueueTelemetryService,
|
||||
spanName: string,
|
||||
jobData: object,
|
||||
processJob: () => T | Promise<T>,
|
||||
onError: (error: Error) => void,
|
||||
): Promise<T> {
|
||||
return telemetryService.startSpan(spanName, async (): Promise<T> => {
|
||||
return telemetryService.startSpanWithTraceContext(spanName, jobData, async (): Promise<T> => {
|
||||
try {
|
||||
return await processJob();
|
||||
} catch (error) {
|
||||
|
||||
@@ -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 { WorldChannel } from './api/stream/channels/world.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,
|
||||
WorldChannel,
|
||||
NoteStreamingHidingService,
|
||||
OpenApiServerService,
|
||||
OAuth2ProviderService,
|
||||
|
||||
@@ -32,6 +32,7 @@ import { HealthServerService } from './HealthServerService.js';
|
||||
import { ClientServerService } from './web/ClientServerService.js';
|
||||
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
|
||||
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
|
||||
import { registerHttpServerInstrumentation } from './http-server-instrumentation.js';
|
||||
import { registerHttpAccessLog } from './http-access-log.js';
|
||||
|
||||
const _dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
@@ -81,6 +82,7 @@ export class ServerService implements OnApplicationShutdown {
|
||||
logger: false,
|
||||
});
|
||||
this.#fastify = fastify;
|
||||
await registerHttpServerInstrumentation(fastify, this.config);
|
||||
registerHttpAccessLog(fastify);
|
||||
|
||||
// HSTS
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import ms from 'ms';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { WorldAvatarService } from '@/core/WorldAvatarService.js';
|
||||
import { WorldAvatarEntityService } from '@/core/entities/WorldAvatarEntityService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['worldAvatar'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:worldAvatar',
|
||||
|
||||
limit: {
|
||||
duration: ms('1day'),
|
||||
max: 10,
|
||||
},
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'WorldAvatarDetailed',
|
||||
},
|
||||
|
||||
errors: {
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', maxLength: 256 },
|
||||
def: { type: 'object', additionalProperties: true },
|
||||
},
|
||||
required: ['name', 'def'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private worldAvatarService: WorldAvatarService,
|
||||
private worldAvatarEntityService: WorldAvatarEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
// TODO: validate avatar
|
||||
|
||||
const avatar = await this.worldAvatarService.create(me, {
|
||||
name: ps.name,
|
||||
def: ps.def,
|
||||
});
|
||||
return await this.worldAvatarEntityService.packDetailed(avatar);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { WorldAvatarService } from '@/core/WorldAvatarService.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['worldAvatar'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'write:worldAvatar',
|
||||
|
||||
errors: {
|
||||
noSuchAvatar: {
|
||||
message: 'No such avatar.',
|
||||
code: 'NO_SUCH_ROOM',
|
||||
id: 'd4e3753d-97bf-4a19-ab8e-21080fbc0f4c',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
avatarId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['avatarId'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private worldAvatarService: WorldAvatarService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const avatar = await this.worldAvatarService.findMyAvatarById(me.id, ps.avatarId);
|
||||
if (avatar == null) {
|
||||
throw new ApiError(meta.errors.noSuchAvatar);
|
||||
}
|
||||
|
||||
await this.worldAvatarService.delete(avatar, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { WorldAvatarService } from '@/core/WorldAvatarService.js';
|
||||
import { WorldAvatarEntityService } from '@/core/entities/WorldAvatarEntityService.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['worldAvatar'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'read:worldAvatar',
|
||||
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'WorldAvatarDetailed',
|
||||
},
|
||||
},
|
||||
|
||||
errors: {
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
|
||||
sinceId: { type: 'string', format: 'misskey:id' },
|
||||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
sinceDate: { type: 'integer' },
|
||||
untilDate: { type: 'integer' },
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private worldAvatarEntityService: WorldAvatarEntityService,
|
||||
private worldAvatarService: WorldAvatarService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||
const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null);
|
||||
|
||||
const avatars = await this.worldAvatarService.getMyAvatarsWithPagination(me.id, ps.limit, sinceId, untilId);
|
||||
return this.worldAvatarEntityService.packDetailedMany(avatars, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { WorldAvatarService } from '@/core/WorldAvatarService.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { WorldAvatarEntityService } from '@/core/entities/WorldAvatarEntityService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['worldAvatar'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'read:worldAvatar',
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'WorldAvatarDetailed',
|
||||
},
|
||||
|
||||
errors: {
|
||||
noSuchAvatar: {
|
||||
message: 'No such avatar.',
|
||||
code: 'NO_SUCH_ROOM',
|
||||
id: '857ae02f-8759-4d20-9adb-6e95fffe4fd8',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
avatarId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['avatarId'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private worldAvatarService: WorldAvatarService,
|
||||
private worldAvatarEntityService: WorldAvatarEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const avatar = await this.worldAvatarService.findAvatarById(ps.avatarId);
|
||||
if (avatar == null) {
|
||||
throw new ApiError(meta.errors.noSuchAvatar);
|
||||
}
|
||||
|
||||
if (avatar.userId !== me.id) {
|
||||
throw new ApiError(meta.errors.noSuchAvatar);
|
||||
}
|
||||
|
||||
return this.worldAvatarEntityService.packDetailed(avatar, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { WorldAvatarService } from '@/core/WorldAvatarService.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['worldAvatar'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'write:worldAvatar',
|
||||
|
||||
res: {
|
||||
},
|
||||
|
||||
errors: {
|
||||
noSuchAvatar: {
|
||||
message: 'No such avatar.',
|
||||
code: 'NO_SUCH_ROOM',
|
||||
id: 'fcdb0f92-bda6-47f9-bd05-343e0e020933',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
avatarId: { type: 'string', format: 'misskey:id' },
|
||||
name: { type: 'string', maxLength: 256 },
|
||||
def: { type: 'object', additionalProperties: true },
|
||||
active: { type: 'boolean' },
|
||||
},
|
||||
required: ['avatarId'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private worldAvatarService: WorldAvatarService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const avatar = await this.worldAvatarService.findMyAvatarById(me.id, ps.avatarId);
|
||||
if (avatar == null) {
|
||||
throw new ApiError(meta.errors.noSuchAvatar);
|
||||
}
|
||||
|
||||
// TODO: validate avatar
|
||||
|
||||
await this.worldAvatarService.update(avatar, {
|
||||
name: ps.name,
|
||||
def: ps.def,
|
||||
active: ps.active,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import ms from 'ms';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { WorldRoomService } from '@/core/WorldRoomService.js';
|
||||
import { WorldRoomEntityService } from '@/core/entities/WorldRoomEntityService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['worldRoom'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
prohibitMoved: true,
|
||||
|
||||
kind: 'write:worldRoom',
|
||||
|
||||
limit: {
|
||||
duration: ms('1day'),
|
||||
max: 10,
|
||||
},
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'WorldRoomDetailed',
|
||||
},
|
||||
|
||||
errors: {
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', maxLength: 256 },
|
||||
description: { type: 'string', maxLength: 1024 },
|
||||
visibility: { type: 'string', enum: ['public', 'private'] },
|
||||
def: { type: 'object', additionalProperties: true },
|
||||
},
|
||||
required: ['name', 'visibility', 'def'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private worldRoomService: WorldRoomService,
|
||||
private worldRoomEntityService: WorldRoomEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
// TODO: validate room
|
||||
|
||||
const room = await this.worldRoomService.create(me, {
|
||||
name: ps.name,
|
||||
description: ps.description ?? '',
|
||||
visibility: ps.visibility,
|
||||
def: ps.def,
|
||||
});
|
||||
return await this.worldRoomEntityService.packDetailed(room);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { WorldRoomService } from '@/core/WorldRoomService.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['worldRoom'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'write:worldRoom',
|
||||
|
||||
errors: {
|
||||
noSuchRoom: {
|
||||
message: 'No such room.',
|
||||
code: 'NO_SUCH_ROOM',
|
||||
id: 'd4e3753d-97bf-4a19-ab8e-21080fbc0f4c',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
roomId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['roomId'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private worldRoomService: WorldRoomService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const room = await this.worldRoomService.findMyRoomById(me.id, ps.roomId);
|
||||
if (room == null) {
|
||||
throw new ApiError(meta.errors.noSuchRoom);
|
||||
}
|
||||
|
||||
await this.worldRoomService.delete(room, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { WorldRoomService } from '@/core/WorldRoomService.js';
|
||||
import { WorldRoomEntityService } from '@/core/entities/WorldRoomEntityService.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['worldRoom'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'read:worldRoom',
|
||||
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'WorldRoomLite',
|
||||
},
|
||||
},
|
||||
|
||||
errors: {
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
userId: { type: 'string', format: 'misskey:id' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
|
||||
sinceId: { type: 'string', format: 'misskey:id' },
|
||||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
sinceDate: { type: 'integer' },
|
||||
untilDate: { type: 'integer' },
|
||||
},
|
||||
required: ['userId'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private worldRoomEntityService: WorldRoomEntityService,
|
||||
private worldRoomService: WorldRoomService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||
const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null);
|
||||
|
||||
const rooms = await this.worldRoomService.getRoomsOfUserWithPagination(ps.userId, ps.userId === me.id, ps.limit, sinceId, untilId);
|
||||
return this.worldRoomEntityService.packLiteMany(rooms, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { WorldRoomService } from '@/core/WorldRoomService.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { WorldRoomEntityService } from '@/core/entities/WorldRoomEntityService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['worldRoom'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'read:worldRoom',
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'WorldRoomDetailed',
|
||||
},
|
||||
|
||||
errors: {
|
||||
noSuchRoom: {
|
||||
message: 'No such room.',
|
||||
code: 'NO_SUCH_ROOM',
|
||||
id: '857ae02f-8759-4d20-9adb-6e95fffe4fd8',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
roomId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['roomId'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private worldRoomService: WorldRoomService,
|
||||
private worldRoomEntityService: WorldRoomEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const room = await this.worldRoomService.findRoomById(ps.roomId);
|
||||
if (room == null) {
|
||||
throw new ApiError(meta.errors.noSuchRoom);
|
||||
}
|
||||
|
||||
if (room.userId !== me.id && room.visibility === 'private') {
|
||||
throw new ApiError(meta.errors.noSuchRoom);
|
||||
}
|
||||
|
||||
return this.worldRoomEntityService.packDetailed(room, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { WorldRoomService } from '@/core/WorldRoomService.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['worldRoom'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'write:worldRoom',
|
||||
|
||||
res: {
|
||||
},
|
||||
|
||||
errors: {
|
||||
noSuchRoom: {
|
||||
message: 'No such room.',
|
||||
code: 'NO_SUCH_ROOM',
|
||||
id: 'fcdb0f92-bda6-47f9-bd05-343e0e020933',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
roomId: { type: 'string', format: 'misskey:id' },
|
||||
name: { type: 'string', maxLength: 256 },
|
||||
description: { type: 'string', maxLength: 1024 },
|
||||
visibility: { type: 'string', enum: ['public', 'private'] },
|
||||
def: { type: 'object', additionalProperties: true },
|
||||
},
|
||||
required: ['roomId'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private worldRoomService: WorldRoomService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const room = await this.worldRoomService.findMyRoomById(me.id, ps.roomId);
|
||||
if (room == null) {
|
||||
throw new ApiError(meta.errors.noSuchRoom);
|
||||
}
|
||||
|
||||
// TODO: validate room
|
||||
|
||||
await this.worldRoomService.update(room, {
|
||||
name: ps.name,
|
||||
description: ps.description,
|
||||
visibility: ps.visibility,
|
||||
def: ps.def,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,6 @@ import { ChatUserChannel } from '@/server/api/stream/channels/chat-user.js';
|
||||
import { ChatRoomChannel } from '@/server/api/stream/channels/chat-room.js';
|
||||
import { ReversiChannel } from '@/server/api/stream/channels/reversi.js';
|
||||
import { ReversiGameChannel } from '@/server/api/stream/channels/reversi-game.js';
|
||||
import { WorldChannel } from '@/server/api/stream/channels/world.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 'world': return WorldChannel;
|
||||
|
||||
default:
|
||||
throw new Error(`no such channel: ${name}`);
|
||||
|
||||
@@ -1,113 +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 { WorldMultiplayService } from '@/core/WorldMultiplayService.js';
|
||||
import Channel, { type ChannelRequest } from '../channel.js';
|
||||
|
||||
@Injectable({ scope: Scope.TRANSIENT })
|
||||
export class WorldChannel extends Channel {
|
||||
public readonly chName = 'world';
|
||||
public static shouldShare = false;
|
||||
public static requireCredential = true as const;
|
||||
public static kind = 'read:world';
|
||||
private roomId: string;
|
||||
private spaceKey: string;
|
||||
private intervalId: NodeJS.Timeout;
|
||||
private isEntered = false;
|
||||
|
||||
constructor(
|
||||
@Inject(REQUEST)
|
||||
request: ChannelRequest,
|
||||
|
||||
private worldRoomService: WorldRoomService,
|
||||
private worldMultiplayService: WorldMultiplayService,
|
||||
) {
|
||||
super(request);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async init(params: JsonObject): Promise<boolean> {
|
||||
if (typeof params.spaceKey !== 'string') return false;
|
||||
if (!this.user) return false;
|
||||
|
||||
this.spaceKey = params.spaceKey;
|
||||
|
||||
try {
|
||||
await this.enter();
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.subscriber.on(`worldStream:${this.spaceKey}`, this.onEvent);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async enter() {
|
||||
if (this.isEntered) return;
|
||||
|
||||
await this.worldMultiplayService.enter(this.user!.id, this.spaceKey);
|
||||
|
||||
this.isEntered = true;
|
||||
|
||||
this.send('entered', {
|
||||
playerProfiles: await this.worldMultiplayService.getPlayerProfiles(this.spaceKey, this.user!.id),
|
||||
});
|
||||
|
||||
this.intervalId = setInterval(async () => {
|
||||
const states = await this.worldMultiplayService.getPlayerStatesAndHeatbeat(this.user!.id, this.spaceKey);
|
||||
delete states[this.user!.id];
|
||||
this.send('sync', states);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async onEvent(data: GlobalEvents['world']['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.worldMultiplayService.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.spaceKey != null && this.isEntered) {
|
||||
this.worldMultiplayService.updatePlayerState(this.user!.id, this.spaceKey, body);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose() {
|
||||
this.subscriber.off(`worldStream:${this.spaceKey}`, this.onEvent);
|
||||
|
||||
clearInterval(this.intervalId);
|
||||
this.worldMultiplayService.left(this.user!.id, this.spaceKey);
|
||||
}
|
||||
}
|
||||
@@ -123,7 +123,7 @@ export function registerHttpAccessLog(fastify: FastifyInstance, manager: LogMana
|
||||
|
||||
const states = new WeakMap<object, AccessRequestState>();
|
||||
|
||||
// リクエスト開始時のactiveなTrace Contextを保存します。
|
||||
// HTTP計装の後に登録し、リクエスト開始時のactiveなTrace Contextを保存します。
|
||||
fastify.addHook('onRequest', (request, _reply, done) => {
|
||||
states.set(request, {
|
||||
traceContext: manager.getActiveTraceContext(),
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { Config } from '@/config.js';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
type TelemetryConfig = Pick<Config, 'otelForBackend' | 'sentryForBackend'>;
|
||||
|
||||
export function shouldRegisterHttpServerInstrumentation(config: TelemetryConfig): boolean {
|
||||
// Sentryもリクエストspanを作成するため、両方を登録すると重複して出力される。
|
||||
return config.otelForBackend != null && config.sentryForBackend == null;
|
||||
}
|
||||
|
||||
/**
|
||||
* すべてのルート・フックより前にリクエスト計装を登録し、ActivityPubや
|
||||
* well-knownを含む全HTTP受信経路を1つのroot spanとして計測する。
|
||||
*/
|
||||
export async function registerHttpServerInstrumentation(fastify: FastifyInstance, config: TelemetryConfig): Promise<void> {
|
||||
if (!shouldRegisterHttpServerInstrumentation(config)) return;
|
||||
|
||||
const { FastifyOtelInstrumentation } = await import('@fastify/otel');
|
||||
const instrumentation = new FastifyOtelInstrumentation({
|
||||
requestHook: (span, request) => {
|
||||
const route = request.routeOptions.url;
|
||||
if (route != null) {
|
||||
// デフォルトだとトレース名が「request」で固定されてしまうため、判別がつかなくなる。
|
||||
// ルート名をspan名に設定することで、トレースビューでルートごとの処理時間を確認できるようになる。
|
||||
span.updateName(`${request.method} ${route}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
await fastify.register(instrumentation.plugin());
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { Queue, Worker } from 'bullmq';
|
||||
import { SpanKind, SpanStatusCode } from '@opentelemetry/api';
|
||||
import { InMemorySpanExporter, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
|
||||
import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
|
||||
import { loadConfig } from '@/config.js';
|
||||
import { installRedisInstrumentation } from '@/core/telemetry/redis-instrumentation.js';
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
describe('Redis telemetry instrumentation', () => {
|
||||
test('records Redis spans below HTTP and BullMQ worker spans without Redis arguments', async () => {
|
||||
const exporter = new InMemorySpanExporter();
|
||||
const provider = new NodeTracerProvider({
|
||||
spanProcessors: [new SimpleSpanProcessor(exporter)],
|
||||
});
|
||||
provider.register();
|
||||
|
||||
const tracer = provider.getTracer('telemetry-redis-instrumentation-test');
|
||||
const uninstall = installRedisInstrumentation(tracer, SpanKind.CLIENT, SpanStatusCode.ERROR, {
|
||||
captureCommandSpans: true,
|
||||
});
|
||||
const queueName = `telemetry-${randomUUID()}`;
|
||||
const prefix = `telemetry-${randomUUID()}`;
|
||||
const connection = {
|
||||
host: config.redis.host,
|
||||
port: config.redis.port,
|
||||
...(config.redis.password != null ? { password: config.redis.password } : {}),
|
||||
};
|
||||
const queue = new Queue(queueName, { connection, prefix });
|
||||
let worker: Worker | undefined;
|
||||
let httpSpanId: string | undefined;
|
||||
let jobSpanId: string | undefined;
|
||||
const secret = `secret-${randomUUID()}`;
|
||||
|
||||
try {
|
||||
const processed = new Promise<void>((resolve, reject) => {
|
||||
worker = new Worker(queueName, async job => {
|
||||
return await tracer.startActiveSpan('Queue: telemetry test', async jobSpan => {
|
||||
jobSpanId = jobSpan.spanContext().spanId;
|
||||
try {
|
||||
// updateData uses BullMQ's worker-side ioredis client.
|
||||
await job.updateData({ secret });
|
||||
return 'ok';
|
||||
} finally {
|
||||
jobSpan.end();
|
||||
}
|
||||
});
|
||||
}, { connection, prefix });
|
||||
worker.once('completed', () => resolve());
|
||||
worker.once('failed', (_job, error) => reject(error));
|
||||
worker.on('error', reject);
|
||||
});
|
||||
|
||||
await tracer.startActiveSpan('HTTP POST /telemetry-test', async httpSpan => {
|
||||
httpSpanId = httpSpan.spanContext().spanId;
|
||||
try {
|
||||
// Queue#add uses BullMQ's producer-side ioredis client.
|
||||
await queue.add('probe', { secret });
|
||||
} finally {
|
||||
httpSpan.end();
|
||||
}
|
||||
});
|
||||
await processed;
|
||||
await provider.forceFlush();
|
||||
|
||||
const redisSpans = exporter.getFinishedSpans().filter(span => span.attributes['db.system.name'] === 'redis');
|
||||
expect(redisSpans.some(span => span.parentSpanContext?.spanId === httpSpanId)).toBe(true);
|
||||
expect(redisSpans.some(span => span.parentSpanContext?.spanId === jobSpanId)).toBe(true);
|
||||
for (const span of redisSpans) {
|
||||
expect(span.attributes).not.toHaveProperty('db.statement');
|
||||
expect(span.attributes).not.toHaveProperty('db.query.text');
|
||||
expect(Object.values(span.attributes)).not.toContain(secret);
|
||||
}
|
||||
} finally {
|
||||
await worker?.close(true);
|
||||
await queue.obliterate({ force: true });
|
||||
await queue.close();
|
||||
uninstall();
|
||||
await provider.shutdown();
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { SpanStatusCode } from '@opentelemetry/api';
|
||||
import { defaultResource, detectResources, envDetector, resourceFromAttributes } from '@opentelemetry/resources';
|
||||
import { ParentBasedSampler, TraceIdRatioBasedSampler } from '@opentelemetry/sdk-trace-base';
|
||||
import { ATTR_SERVICE_INSTANCE_ID, ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
|
||||
import type { Context, SpanContext } from '@opentelemetry/api';
|
||||
import { OpenTelemetryAdapter, createResource, createSampler, getMisskeyProcessRole } from '@/core/telemetry/adapters/OpenTelemetryAdapter.js';
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
return {
|
||||
envOption: {
|
||||
disableClustering: false,
|
||||
onlyServer: false,
|
||||
onlyQueue: false,
|
||||
},
|
||||
isPrimary: false,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/env.js', () => ({
|
||||
envOption: mocks.envOption,
|
||||
}));
|
||||
|
||||
vi.mock('node:cluster', () => ({
|
||||
default: {
|
||||
get isPrimary() {
|
||||
return mocks.isPrimary;
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const samplerDeps = {
|
||||
ParentBasedSampler,
|
||||
TraceIdRatioBasedSampler,
|
||||
};
|
||||
|
||||
describe('OpenTelemetryAdapter', () => {
|
||||
test('wraps async work in an active span and ends it after success', async () => {
|
||||
const span = {
|
||||
end: vi.fn(),
|
||||
recordException: vi.fn(),
|
||||
setStatus: vi.fn(),
|
||||
};
|
||||
const tracer = {
|
||||
startActiveSpan: vi.fn(async (_name: string, fn: (spanArg: any) => Promise<string>) => fn(span)),
|
||||
} as any;
|
||||
const provider = {
|
||||
shutdown: vi.fn(),
|
||||
};
|
||||
const adapter = new OpenTelemetryAdapter({
|
||||
tracer,
|
||||
provider,
|
||||
getActiveSpan: () => undefined,
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
shutdownTimeout: 10,
|
||||
});
|
||||
|
||||
await expect(adapter.startSpan('API: test', async () => 'ok')).resolves.toBe('ok');
|
||||
|
||||
expect(tracer.startActiveSpan).toHaveBeenCalledWith('API: test', expect.any(Function));
|
||||
expect(span.recordException).not.toHaveBeenCalled();
|
||||
expect(span.setStatus).not.toHaveBeenCalled();
|
||||
expect(span.end).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('records thrown errors on the active span before rethrowing', async () => {
|
||||
const error = new Error('boom');
|
||||
const span = {
|
||||
end: vi.fn(),
|
||||
recordException: vi.fn(),
|
||||
setStatus: vi.fn(),
|
||||
};
|
||||
const tracer = {
|
||||
startActiveSpan: vi.fn(async (_name: string, fn: (spanArg: any) => Promise<void>) => fn(span)),
|
||||
} as any;
|
||||
const adapter = new OpenTelemetryAdapter({
|
||||
tracer,
|
||||
provider: { shutdown: vi.fn() },
|
||||
getActiveSpan: () => undefined,
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
shutdownTimeout: 10,
|
||||
});
|
||||
|
||||
await expect(adapter.startSpan('Queue: test', async () => {
|
||||
throw error;
|
||||
})).rejects.toThrow(error);
|
||||
|
||||
expect(span.recordException).toHaveBeenCalledWith(error);
|
||||
expect(span.setStatus).toHaveBeenCalledWith({
|
||||
code: SpanStatusCode.ERROR,
|
||||
message: error.message,
|
||||
});
|
||||
expect(span.end).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('creates a root worker span linked to the enqueue span by default', () => {
|
||||
const span = {
|
||||
end: vi.fn(),
|
||||
recordException: vi.fn(),
|
||||
setStatus: vi.fn(),
|
||||
};
|
||||
const rootContext = {} as Context;
|
||||
const extractedContext = {} as Context;
|
||||
const sourceSpanContext = {
|
||||
traceId: '0123456789abcdef0123456789abcdef',
|
||||
spanId: '0123456789abcdef',
|
||||
traceFlags: 1,
|
||||
isRemote: true,
|
||||
} as SpanContext;
|
||||
const propagation = {
|
||||
isPropagationApi: true,
|
||||
inject: vi.fn(),
|
||||
extract(this: { isPropagationApi: boolean }) {
|
||||
if (!this.isPropagationApi) throw new Error('lost propagation API receiver');
|
||||
return extractedContext;
|
||||
},
|
||||
};
|
||||
const tracer = {
|
||||
startActiveSpan: vi.fn((_name: string, _options: unknown, _context: unknown, fn: (spanArg: typeof span) => string) => fn(span)),
|
||||
} as any;
|
||||
const adapter = new OpenTelemetryAdapter({
|
||||
tracer,
|
||||
provider: { shutdown: vi.fn() },
|
||||
getActiveSpan: () => undefined,
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
shutdownTimeout: 10,
|
||||
queueTraceContext: {
|
||||
tracer,
|
||||
propagation: propagation as any,
|
||||
trace: { getSpanContext: () => sourceSpanContext },
|
||||
getActiveContext: () => rootContext,
|
||||
rootContext,
|
||||
mode: 'link',
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
},
|
||||
});
|
||||
|
||||
expect(adapter.startSpanWithTraceContext('Queue: Deliver', {
|
||||
__misskeyTraceContext: {
|
||||
traceparent: '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01',
|
||||
},
|
||||
}, () => 'ok')).toBe('ok');
|
||||
|
||||
expect(tracer.startActiveSpan).toHaveBeenCalledWith('Queue: Deliver', {
|
||||
root: true,
|
||||
links: [{ context: sourceSpanContext }],
|
||||
}, rootContext, expect.any(Function));
|
||||
expect(span.end).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('returns the active span context for log enrichment', () => {
|
||||
const adapter = new OpenTelemetryAdapter({
|
||||
tracer: { startActiveSpan: vi.fn() },
|
||||
provider: { shutdown: vi.fn() },
|
||||
getActiveSpan: () => ({
|
||||
spanContext: () => ({
|
||||
traceId: '0123456789abcdef0123456789abcdef',
|
||||
spanId: '0123456789abcdef',
|
||||
traceFlags: 0,
|
||||
}),
|
||||
} as any),
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
shutdownTimeout: 10,
|
||||
});
|
||||
|
||||
expect(adapter.getActiveTraceContext()).toEqual({
|
||||
traceId: '0123456789abcdef0123456789abcdef',
|
||||
spanId: '0123456789abcdef',
|
||||
traceFlags: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('bridges captureMessage to the active span when one exists', () => {
|
||||
const activeSpan = {
|
||||
recordException: vi.fn(),
|
||||
setStatus: vi.fn(),
|
||||
};
|
||||
const adapter = new OpenTelemetryAdapter({
|
||||
tracer: { startActiveSpan: vi.fn() },
|
||||
provider: { shutdown: vi.fn() },
|
||||
getActiveSpan: () => activeSpan as any,
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
shutdownTimeout: 10,
|
||||
});
|
||||
|
||||
adapter.captureMessage('Queue failed', {
|
||||
level: 'error',
|
||||
extra: { queue: 'deliver' },
|
||||
});
|
||||
|
||||
expect(activeSpan.recordException).toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: 'Queue failed',
|
||||
}));
|
||||
expect(activeSpan.setStatus).toHaveBeenCalledWith({
|
||||
code: SpanStatusCode.ERROR,
|
||||
message: 'Queue failed',
|
||||
});
|
||||
});
|
||||
|
||||
test('times out shutdown instead of waiting forever', async () => {
|
||||
vi.useFakeTimers();
|
||||
const adapter = new OpenTelemetryAdapter({
|
||||
tracer: { startActiveSpan: vi.fn() },
|
||||
provider: { shutdown: vi.fn(() => new Promise<void>(() => {})) },
|
||||
getActiveSpan: () => undefined,
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
shutdownTimeout: 50,
|
||||
});
|
||||
|
||||
const shutdown = adapter.shutdown();
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
await expect(shutdown).resolves.toBeUndefined();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('clears the shutdown timeout timer once provider.shutdown() resolves first', async () => {
|
||||
vi.useFakeTimers();
|
||||
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
|
||||
const adapter = new OpenTelemetryAdapter({
|
||||
tracer: { startActiveSpan: vi.fn() },
|
||||
provider: { shutdown: vi.fn().mockResolvedValue(undefined) },
|
||||
getActiveSpan: () => undefined,
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
shutdownTimeout: 5000,
|
||||
});
|
||||
|
||||
await adapter.shutdown();
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
clearTimeoutSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('captureMessage starts a standalone span to report the error when there is no active span', () => {
|
||||
const reportSpan = {
|
||||
end: vi.fn(),
|
||||
recordException: vi.fn(),
|
||||
setStatus: vi.fn(),
|
||||
};
|
||||
const tracer = {
|
||||
startActiveSpan: vi.fn((_name: string, fn: (spanArg: typeof reportSpan) => void) => fn(reportSpan)),
|
||||
};
|
||||
const adapter = new OpenTelemetryAdapter({
|
||||
tracer: tracer as any,
|
||||
provider: { shutdown: vi.fn() },
|
||||
getActiveSpan: () => undefined,
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
shutdownTimeout: 10,
|
||||
});
|
||||
|
||||
adapter.captureMessage('Queue: Deliver failed', {
|
||||
level: 'error',
|
||||
extra: { queue: 'deliver' },
|
||||
});
|
||||
|
||||
expect(tracer.startActiveSpan).toHaveBeenCalledWith('captureMessage', expect.any(Function));
|
||||
expect(reportSpan.recordException).toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: 'Queue: Deliver failed',
|
||||
}));
|
||||
expect(reportSpan.setStatus).toHaveBeenCalledWith({
|
||||
code: SpanStatusCode.ERROR,
|
||||
message: 'Queue: Deliver failed',
|
||||
});
|
||||
expect(reportSpan.end).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSampler', () => {
|
||||
test('accepts sample rates within [0, 1]', () => {
|
||||
expect(() => createSampler(0, samplerDeps)).not.toThrow();
|
||||
expect(() => createSampler(0.5, samplerDeps)).not.toThrow();
|
||||
expect(() => createSampler(1, samplerDeps)).not.toThrow();
|
||||
});
|
||||
|
||||
test('rejects sample rates outside [0, 1]', () => {
|
||||
expect(() => createSampler(-0.1, samplerDeps)).toThrow();
|
||||
expect(() => createSampler(1.1, samplerDeps)).toThrow();
|
||||
});
|
||||
|
||||
test('rejects NaN instead of silently disabling sampling', () => {
|
||||
expect(() => createSampler(Number.NaN, samplerDeps)).toThrow();
|
||||
});
|
||||
|
||||
test('rejects non-number values that pass through YAML as strings', () => {
|
||||
expect(() => createSampler('0.5' as unknown as number, samplerDeps)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createResource', () => {
|
||||
test('lets explicit config override OTEL resource env, and env override Misskey defaults', () => {
|
||||
const previousServiceName = process.env['OTEL_SERVICE_NAME'];
|
||||
const previousResourceAttributes = process.env['OTEL_RESOURCE_ATTRIBUTES'];
|
||||
process.env['OTEL_SERVICE_NAME'] = 'env-service';
|
||||
process.env['OTEL_RESOURCE_ATTRIBUTES'] = [
|
||||
'deployment.environment=staging',
|
||||
'misskey.process.role=env-role',
|
||||
'service.instance.id=env-instance',
|
||||
'env.only=value',
|
||||
].join(',');
|
||||
|
||||
try {
|
||||
const resource = createResource({
|
||||
serviceVersion: '2026.1.0',
|
||||
resourceAttributes: {
|
||||
[ATTR_SERVICE_NAME]: 'config-service',
|
||||
'deployment.environment': 'production',
|
||||
'config.only': 'value',
|
||||
},
|
||||
}, {
|
||||
defaultResource,
|
||||
resourceFromAttributes,
|
||||
detectResources,
|
||||
envDetector,
|
||||
serviceNameAttribute: ATTR_SERVICE_NAME,
|
||||
serviceInstanceIdAttribute: ATTR_SERVICE_INSTANCE_ID,
|
||||
serviceVersionAttribute: ATTR_SERVICE_VERSION,
|
||||
serviceVersion: '2026.1.0',
|
||||
});
|
||||
|
||||
expect(resource.attributes[ATTR_SERVICE_NAME]).toBe('config-service');
|
||||
expect(resource.attributes[ATTR_SERVICE_INSTANCE_ID]).toBe('env-instance');
|
||||
expect(resource.attributes[ATTR_SERVICE_VERSION]).toBe('2026.1.0');
|
||||
expect(resource.attributes['deployment.environment']).toBe('production');
|
||||
expect(resource.attributes['misskey.process.role']).toBe('env-role');
|
||||
expect(resource.attributes['env.only']).toBe('value');
|
||||
expect(resource.attributes['config.only']).toBe('value');
|
||||
} finally {
|
||||
if (previousServiceName == null) {
|
||||
delete process.env['OTEL_SERVICE_NAME'];
|
||||
} else {
|
||||
process.env['OTEL_SERVICE_NAME'] = previousServiceName;
|
||||
}
|
||||
if (previousResourceAttributes == null) {
|
||||
delete process.env['OTEL_RESOURCE_ATTRIBUTES'];
|
||||
} else {
|
||||
process.env['OTEL_RESOURCE_ATTRIBUTES'] = previousResourceAttributes;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMisskeyProcessRole', () => {
|
||||
beforeEach(() => {
|
||||
mocks.envOption.disableClustering = false;
|
||||
mocks.envOption.onlyServer = false;
|
||||
mocks.envOption.onlyQueue = false;
|
||||
mocks.isPrimary = false;
|
||||
});
|
||||
|
||||
test('labels non-clustered onlyServer as primary-server', () => {
|
||||
mocks.envOption.disableClustering = true;
|
||||
mocks.envOption.onlyServer = true;
|
||||
expect(getMisskeyProcessRole()).toBe('primary-server');
|
||||
});
|
||||
|
||||
test('labels clustered primary with onlyServer as fork-only', () => {
|
||||
mocks.isPrimary = true;
|
||||
mocks.envOption.onlyServer = true;
|
||||
expect(getMisskeyProcessRole()).toBe('fork-only');
|
||||
});
|
||||
|
||||
test('labels clustered worker running the HTTP server (onlyServer) as worker-server, not worker-queue', () => {
|
||||
mocks.isPrimary = false;
|
||||
mocks.envOption.onlyServer = true;
|
||||
expect(getMisskeyProcessRole()).toBe('worker-server');
|
||||
});
|
||||
|
||||
test('labels clustered worker without onlyServer as worker-queue', () => {
|
||||
mocks.isPrimary = false;
|
||||
mocks.envOption.onlyServer = false;
|
||||
expect(getMisskeyProcessRole()).toBe('worker-queue');
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,11 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { SentryTelemetryAdapter, buildSentryIntegrations, buildSentryNodeOptions } from '@/core/telemetry/adapters/SentryTelemetryAdapter.js';
|
||||
import { normalizeSentryBackendConfig } from '@/config.js';
|
||||
import { SentryTelemetryAdapter, buildSentryIntegrations, buildSentryNodeOptions, buildSentryOtlpInitOptions } from '@/core/telemetry/adapters/SentryTelemetryAdapter.js';
|
||||
|
||||
type TestIntegration = Parameters<ReturnType<typeof buildSentryIntegrations>>[0][number];
|
||||
type TestSpanProcessor = NonNullable<ReturnType<typeof buildSentryOtlpInitOptions>['openTelemetrySpanProcessors']>[number];
|
||||
|
||||
function testIntegration(name: string): TestIntegration {
|
||||
return { name };
|
||||
@@ -35,7 +37,9 @@ describe('SentryTelemetryAdapter', () => {
|
||||
nodeProfilingIntegration: () => testIntegration('ProfilingIntegration'),
|
||||
});
|
||||
|
||||
const result = integrations([testIntegration('Http')]);
|
||||
const result = integrations([
|
||||
testIntegration('Http'),
|
||||
]);
|
||||
|
||||
expect(result.map((integration: TestIntegration) => integration.name)).toEqual(['Http', 'ProfilingIntegration']);
|
||||
});
|
||||
@@ -48,7 +52,9 @@ describe('SentryTelemetryAdapter', () => {
|
||||
warn,
|
||||
});
|
||||
|
||||
const result = integrations([testIntegration('Http')]);
|
||||
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');
|
||||
@@ -73,6 +79,104 @@ describe('SentryTelemetryAdapter', () => {
|
||||
|
||||
expect(options.tracePropagationTargets).toEqual(['^https://internal\\.example/']);
|
||||
});
|
||||
|
||||
test('builds Sentry options that export spans to both Sentry and OTLP', () => {
|
||||
const existingProcessor = { name: 'existingProcessor' } as unknown as TestSpanProcessor;
|
||||
const otlpProcessor = { name: 'otlpProcessor' };
|
||||
|
||||
const result = buildSentryOtlpInitOptions({
|
||||
sentryConfig: {
|
||||
enableNodeProfiling: false,
|
||||
disabledIntegrations: ['Redis'],
|
||||
options: {
|
||||
openTelemetrySpanProcessors: [existingProcessor],
|
||||
tracesSampleRate: 0.25,
|
||||
},
|
||||
},
|
||||
otelConfig: { serviceVersion: '2026.1.0' },
|
||||
otlpProcessor,
|
||||
});
|
||||
|
||||
expect(result.tracesSampleRate).toBe(0.25);
|
||||
expect(result.openTelemetrySpanProcessors).toEqual([existingProcessor, otlpProcessor]);
|
||||
// OTel併存時もremoteへtrace headerを漏らさないデフォルトはSentry単体時と揃える。
|
||||
expect(result.tracePropagationTargets).toEqual([]);
|
||||
expect(result.integrations).toBeTypeOf('function');
|
||||
if (typeof result.integrations !== 'function') throw new Error('Expected integrations to be a function');
|
||||
expect(result.integrations([
|
||||
testIntegration('Http'),
|
||||
testIntegration('Redis'),
|
||||
testIntegration('Postgres'),
|
||||
]).map((integration: TestIntegration) => integration.name)).toEqual(['Http', 'Postgres']);
|
||||
});
|
||||
|
||||
test('uses safe defaults when Sentry options are omitted', () => {
|
||||
const otlpProcessor = { name: 'otlpProcessor' };
|
||||
const result = buildSentryOtlpInitOptions({
|
||||
sentryConfig: normalizeSentryBackendConfig({
|
||||
enableNodeProfiling: false,
|
||||
}),
|
||||
otelConfig: { serviceVersion: '2026.1.0' },
|
||||
otlpProcessor,
|
||||
});
|
||||
|
||||
expect(result.tracePropagationTargets).toEqual([]);
|
||||
expect(result.openTelemetrySpanProcessors).toEqual([otlpProcessor]);
|
||||
});
|
||||
|
||||
test('does not disable Sentry trace propagation when explicitly enabled for OTel coexistence', () => {
|
||||
const result = buildSentryOtlpInitOptions({
|
||||
sentryConfig: {
|
||||
enableNodeProfiling: false,
|
||||
options: {},
|
||||
},
|
||||
otelConfig: {
|
||||
serviceVersion: '2026.1.0',
|
||||
propagateTraceToRemote: true,
|
||||
},
|
||||
otlpProcessor: { name: 'otlpProcessor' },
|
||||
});
|
||||
|
||||
expect(result.tracePropagationTargets).toBeUndefined();
|
||||
});
|
||||
|
||||
test('honors explicit tracePropagationTargets for OTel coexistence even without propagateTraceToRemote', () => {
|
||||
const result = buildSentryOtlpInitOptions({
|
||||
sentryConfig: {
|
||||
enableNodeProfiling: false,
|
||||
options: {
|
||||
tracePropagationTargets: ['^https://internal\\.example/'],
|
||||
},
|
||||
},
|
||||
otelConfig: { serviceVersion: '2026.1.0' },
|
||||
otlpProcessor: { name: 'otlpProcessor' },
|
||||
});
|
||||
|
||||
expect(result.tracePropagationTargets).toEqual(['^https://internal\\.example/']);
|
||||
});
|
||||
|
||||
test('warns when OTel-only options are ignored in Sentry coexistence mode', () => {
|
||||
const warn = vi.fn();
|
||||
|
||||
buildSentryOtlpInitOptions({
|
||||
sentryConfig: {
|
||||
enableNodeProfiling: false,
|
||||
options: {},
|
||||
},
|
||||
otelConfig: {
|
||||
serviceVersion: '2026.1.0',
|
||||
sampleRate: 0.25,
|
||||
resourceAttributes: {
|
||||
'deployment.environment': 'production',
|
||||
},
|
||||
},
|
||||
otlpProcessor: { name: 'otlpProcessor' },
|
||||
warn,
|
||||
});
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('otelForBackend.sampleRate is ignored'));
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('otelForBackend.resourceAttributes is ignored'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('SentryTelemetryAdapter trace context', () => {
|
||||
@@ -134,3 +238,69 @@ describe('SentryTelemetryAdapter.shutdown', () => {
|
||||
vi.doUnmock('@sentry/profiling-node');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SentryTelemetryAdapter.createWithOtlpExport', () => {
|
||||
test('registers the OTel diag logger before creating the OTLP exporter', async () => {
|
||||
const init = vi.fn();
|
||||
const close = vi.fn();
|
||||
const setLogger = vi.fn();
|
||||
const nodeProfilingIntegration = vi.fn();
|
||||
const BatchSpanProcessor = vi.fn(function (this: { exporter: unknown }, exporter: unknown) {
|
||||
this.exporter = exporter;
|
||||
});
|
||||
const OTLPTraceExporter = vi.fn(function (this: { options: unknown }, options: unknown) {
|
||||
this.options = options;
|
||||
});
|
||||
|
||||
vi.doMock('@sentry/node', () => ({
|
||||
init,
|
||||
close,
|
||||
}));
|
||||
vi.doMock('@sentry/profiling-node', () => ({
|
||||
nodeProfilingIntegration,
|
||||
}));
|
||||
vi.doMock('@opentelemetry/api', () => ({
|
||||
context: { active: vi.fn() },
|
||||
diag: { setLogger },
|
||||
DiagLogLevel: { WARN: 50 },
|
||||
propagation: { inject: vi.fn(), extract: vi.fn() },
|
||||
ROOT_CONTEXT: {},
|
||||
SpanStatusCode: { ERROR: 2 },
|
||||
trace: { getTracer: vi.fn(), getSpanContext: vi.fn() },
|
||||
}));
|
||||
vi.doMock('@opentelemetry/sdk-trace-base', () => ({
|
||||
BatchSpanProcessor,
|
||||
}));
|
||||
vi.doMock('@opentelemetry/exporter-trace-otlp-proto', () => ({
|
||||
OTLPTraceExporter,
|
||||
}));
|
||||
|
||||
await SentryTelemetryAdapter.createWithOtlpExport({
|
||||
enableNodeProfiling: false,
|
||||
options: {},
|
||||
}, {
|
||||
serviceVersion: '2026.1.0',
|
||||
endpoint: 'http://collector:4318/v1/traces',
|
||||
});
|
||||
|
||||
expect(setLogger).toHaveBeenCalledWith(expect.objectContaining({
|
||||
error: expect.any(Function),
|
||||
warn: expect.any(Function),
|
||||
}), {
|
||||
logLevel: 50,
|
||||
suppressOverrideMessage: true,
|
||||
});
|
||||
expect(OTLPTraceExporter).toHaveBeenCalledWith({
|
||||
url: 'http://collector:4318/v1/traces',
|
||||
});
|
||||
expect(init).toHaveBeenCalledWith(expect.objectContaining({
|
||||
openTelemetrySpanProcessors: [expect.any(Object)],
|
||||
}));
|
||||
|
||||
vi.doUnmock('@sentry/node');
|
||||
vi.doUnmock('@sentry/profiling-node');
|
||||
vi.doUnmock('@opentelemetry/api');
|
||||
vi.doUnmock('@opentelemetry/sdk-trace-base');
|
||||
vi.doUnmock('@opentelemetry/exporter-trace-otlp-proto');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { SpanKind, SpanStatusCode } from '@opentelemetry/api';
|
||||
import { createHttpClientInstrumentation } from '@/core/telemetry/http-client-instrumentation.js';
|
||||
|
||||
function request() {
|
||||
return {
|
||||
method: 'POST',
|
||||
protocol: 'https:',
|
||||
path: '/inbox?token=secret',
|
||||
host: 'remote.example',
|
||||
getHeader: vi.fn((name: string) => name === 'host' ? 'user:password@remote.example:8443' : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe('http-client-instrumentation', () => {
|
||||
test('creates and completes a sanitized CLIENT span from diagnostics channels', () => {
|
||||
const listeners = new Map<string, (message: unknown) => void>();
|
||||
const span = {
|
||||
end: vi.fn(),
|
||||
recordException: vi.fn(),
|
||||
setAttribute: vi.fn(),
|
||||
setStatus: vi.fn(),
|
||||
};
|
||||
const tracer = { startSpan: vi.fn(() => span) } as any;
|
||||
const unsubscribe = createHttpClientInstrumentation({
|
||||
tracer,
|
||||
spanKindClient: SpanKind.CLIENT,
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
subscribe: (name, listener) => {
|
||||
listeners.set(name, listener);
|
||||
return () => listeners.delete(name);
|
||||
},
|
||||
});
|
||||
const clientRequest = request();
|
||||
|
||||
listeners.get('http.client.request.created')!({ request: clientRequest });
|
||||
listeners.get('http.client.response.finish')!({
|
||||
request: clientRequest,
|
||||
response: { statusCode: 201, httpVersion: '1.1' },
|
||||
});
|
||||
|
||||
expect(tracer.startSpan).toHaveBeenCalledWith('POST', {
|
||||
kind: SpanKind.CLIENT,
|
||||
attributes: {
|
||||
'http.request.method': 'POST',
|
||||
'url.full': 'https://remote.example:8443/inbox',
|
||||
'server.address': 'remote.example',
|
||||
'server.port': 8443,
|
||||
},
|
||||
});
|
||||
expect(span.setAttribute).toHaveBeenCalledWith('http.response.status_code', 201);
|
||||
expect(span.setAttribute).toHaveBeenCalledWith('network.protocol.version', '1.1');
|
||||
expect(span.end).toHaveBeenCalledTimes(1);
|
||||
unsubscribe();
|
||||
expect(listeners.size).toBe(0);
|
||||
});
|
||||
|
||||
test('records a request error and ends the span once', () => {
|
||||
const listeners = new Map<string, (message: unknown) => void>();
|
||||
const span = { end: vi.fn(), recordException: vi.fn(), setAttribute: vi.fn(), setStatus: vi.fn() };
|
||||
const error = Object.assign(new Error('connection refused'), { code: 'ECONNREFUSED' });
|
||||
const clientRequest = request();
|
||||
createHttpClientInstrumentation({
|
||||
tracer: { startSpan: vi.fn(() => span) } as any,
|
||||
spanKindClient: SpanKind.CLIENT,
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
subscribe: (name, listener) => {
|
||||
listeners.set(name, listener);
|
||||
return () => listeners.delete(name);
|
||||
},
|
||||
});
|
||||
|
||||
listeners.get('http.client.request.created')!({ request: clientRequest });
|
||||
listeners.get('http.client.request.error')!({ request: clientRequest, error });
|
||||
listeners.get('http.client.response.finish')!({ request: clientRequest, response: { statusCode: 200 } });
|
||||
|
||||
expect(span.recordException).toHaveBeenCalledWith(error);
|
||||
expect(span.setAttribute).toHaveBeenCalledWith('error.type', 'ECONNREFUSED');
|
||||
expect(span.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR });
|
||||
expect(span.end).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('records the response status code as error.type for an error response', () => {
|
||||
const listeners = new Map<string, (message: unknown) => void>();
|
||||
const span = { end: vi.fn(), recordException: vi.fn(), setAttribute: vi.fn(), setStatus: vi.fn() };
|
||||
const clientRequest = request();
|
||||
createHttpClientInstrumentation({
|
||||
tracer: { startSpan: vi.fn(() => span) } as any,
|
||||
spanKindClient: SpanKind.CLIENT,
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
subscribe: (name, listener) => {
|
||||
listeners.set(name, listener);
|
||||
return () => listeners.delete(name);
|
||||
},
|
||||
});
|
||||
|
||||
listeners.get('http.client.request.created')!({ request: clientRequest });
|
||||
listeners.get('http.client.response.finish')!({ request: clientRequest, response: { statusCode: 502 } });
|
||||
|
||||
expect(span.setAttribute).toHaveBeenCalledWith('error.type', '502');
|
||||
expect(span.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR });
|
||||
});
|
||||
|
||||
test('uses safe request details when the request URL cannot be constructed', () => {
|
||||
const listeners = new Map<string, (message: unknown) => void>();
|
||||
const span = { end: vi.fn(), recordException: vi.fn(), setAttribute: vi.fn(), setStatus: vi.fn() };
|
||||
const tracer = { startSpan: vi.fn(() => span) };
|
||||
createHttpClientInstrumentation({
|
||||
tracer: tracer as any,
|
||||
spanKindClient: SpanKind.CLIENT,
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
subscribe: (name, listener) => {
|
||||
listeners.set(name, listener);
|
||||
return () => listeners.delete(name);
|
||||
},
|
||||
});
|
||||
const clientRequest = {
|
||||
...request(),
|
||||
host: '::1',
|
||||
getHeader: vi.fn(() => undefined),
|
||||
};
|
||||
|
||||
listeners.get('http.client.request.created')!({ request: clientRequest });
|
||||
|
||||
expect(tracer.startSpan).toHaveBeenCalledWith('POST', {
|
||||
kind: SpanKind.CLIENT,
|
||||
attributes: {
|
||||
'http.request.method': 'POST',
|
||||
'url.full': 'https://localhost/',
|
||||
'server.address': 'localhost',
|
||||
'server.port': 443,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('does not propagate errors from diagnostics listeners', () => {
|
||||
const listeners = new Map<string, (message: unknown) => void>();
|
||||
const reportError = vi.fn();
|
||||
const responseError = new Error('response instrumentation failed');
|
||||
const requestError = new Error('request instrumentation failed');
|
||||
const startError = new Error('span creation failed');
|
||||
const span = {
|
||||
end: vi.fn(),
|
||||
recordException: vi.fn(() => { throw requestError; }),
|
||||
setAttribute: vi.fn(() => { throw responseError; }),
|
||||
setStatus: vi.fn(),
|
||||
};
|
||||
const tracer = {
|
||||
startSpan: vi.fn()
|
||||
.mockReturnValueOnce(span)
|
||||
.mockReturnValueOnce(span)
|
||||
.mockImplementationOnce(() => { throw startError; }),
|
||||
};
|
||||
createHttpClientInstrumentation({
|
||||
tracer: tracer as any,
|
||||
spanKindClient: SpanKind.CLIENT,
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
subscribe: (name, listener) => {
|
||||
listeners.set(name, listener);
|
||||
return () => listeners.delete(name);
|
||||
},
|
||||
reportError,
|
||||
});
|
||||
const responseRequest = request();
|
||||
const errorRequest = request();
|
||||
|
||||
listeners.get('http.client.request.created')!({ request: responseRequest });
|
||||
expect(() => listeners.get('http.client.response.finish')!({
|
||||
request: responseRequest,
|
||||
response: { statusCode: 200 },
|
||||
})).not.toThrow();
|
||||
listeners.get('http.client.request.created')!({ request: errorRequest });
|
||||
expect(() => listeners.get('http.client.request.error')!({
|
||||
request: errorRequest,
|
||||
error: new Error('connection failed'),
|
||||
})).not.toThrow();
|
||||
expect(() => listeners.get('http.client.request.created')!({ request: request() })).not.toThrow();
|
||||
|
||||
expect(reportError).toHaveBeenCalledWith(responseError);
|
||||
expect(reportError).toHaveBeenCalledWith(requestError);
|
||||
expect(reportError).toHaveBeenCalledWith(startError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import type * as Bull from 'bullmq';
|
||||
import { instrumentQueue } from '@/core/telemetry/queue-instrumentation.js';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
injectTraceContext: vi.fn((carrier: Record<string, string>) => {
|
||||
carrier['traceparent'] = '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01';
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/core/telemetry/telemetry-registry.js', () => ({
|
||||
injectTraceContext: mocks.injectTraceContext,
|
||||
}));
|
||||
|
||||
describe('queue-instrumentation', () => {
|
||||
beforeEach(() => {
|
||||
mocks.injectTraceContext.mockClear();
|
||||
});
|
||||
|
||||
test('injects the active trace context for add()', () => {
|
||||
const add = vi.fn();
|
||||
const queue = instrumentQueue({ add, addBulk: vi.fn() } as unknown as Bull.Queue<{ noteId: string }>);
|
||||
const data = { noteId: '9d6b9a65-46c9-4e1b-a640-9589693893c9' };
|
||||
|
||||
queue.add('endedPollNotification', data);
|
||||
|
||||
expect(mocks.injectTraceContext).toHaveBeenCalledTimes(1);
|
||||
expect(data).toMatchObject({
|
||||
__misskeyTraceContext: {
|
||||
traceparent: '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01',
|
||||
},
|
||||
});
|
||||
expect(add).toHaveBeenCalledWith('endedPollNotification', data, undefined);
|
||||
});
|
||||
|
||||
test('injects every job passed to addBulk()', () => {
|
||||
const addBulk = vi.fn();
|
||||
const queue = instrumentQueue({ add: vi.fn(), addBulk } as unknown as Bull.Queue<{ to: string }>);
|
||||
const jobs = [
|
||||
{ name: 'deliver', data: { to: 'https://remote.example/inbox' } },
|
||||
{ name: 'deliver', data: { to: 'https://remote2.example/inbox' } },
|
||||
];
|
||||
|
||||
queue.addBulk(jobs);
|
||||
|
||||
expect(mocks.injectTraceContext).toHaveBeenCalledTimes(2);
|
||||
expect(jobs).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ data: expect.objectContaining({ __misskeyTraceContext: expect.any(Object) }) }),
|
||||
]));
|
||||
expect(addBulk).toHaveBeenCalledWith(jobs);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import type { Context, SpanContext } from '@opentelemetry/api';
|
||||
import { getQueueSpanContext, getQueueTraceContextMode, injectActiveTraceContext, injectQueueTraceContext } from '@/core/telemetry/queue-trace-context.js';
|
||||
|
||||
const rootContext = { kind: 'root' } as unknown as Context;
|
||||
const extractedContext = { kind: 'extracted' } as unknown as Context;
|
||||
const sourceSpanContext: SpanContext = {
|
||||
traceId: '0123456789abcdef0123456789abcdef',
|
||||
spanId: '0123456789abcdef',
|
||||
traceFlags: 1,
|
||||
isRemote: true,
|
||||
};
|
||||
|
||||
function jobData() {
|
||||
return {
|
||||
name: 'deliver',
|
||||
__misskeyTraceContext: {
|
||||
traceparent: '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('queue-trace-context', () => {
|
||||
test('stores only a non-empty carrier in the job data', () => {
|
||||
const data = { noteId: '9d6b9a65-46c9-4e1b-a640-9589693893c9' };
|
||||
|
||||
injectQueueTraceContext(data, carrier => {
|
||||
carrier['traceparent'] = '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01';
|
||||
});
|
||||
|
||||
expect(data).toMatchObject({
|
||||
__misskeyTraceContext: {
|
||||
traceparent: '00-0123456789abcdef0123456789abcdef-0123456789abcdef-01',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('does not store an empty carrier when no active trace exists', () => {
|
||||
const data = { noteId: '9d6b9a65-46c9-4e1b-a640-9589693893c9' };
|
||||
|
||||
injectQueueTraceContext(data, () => {});
|
||||
|
||||
expect(data).not.toHaveProperty('__misskeyTraceContext');
|
||||
});
|
||||
|
||||
test('ignores non-object job data', () => {
|
||||
const inject = vi.fn();
|
||||
|
||||
injectQueueTraceContext(null, inject);
|
||||
injectQueueTraceContext('not a job object', inject);
|
||||
|
||||
expect(inject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('injects the active context with the configured propagator', () => {
|
||||
const activeContext = {} as Context;
|
||||
const carrier = {};
|
||||
const inject = vi.fn();
|
||||
|
||||
injectActiveTraceContext({
|
||||
tracer: { startActiveSpan: vi.fn() } as any,
|
||||
propagation: { inject, extract: vi.fn() } as any,
|
||||
trace: { getSpanContext: vi.fn() },
|
||||
getActiveContext: () => activeContext,
|
||||
rootContext,
|
||||
mode: 'link',
|
||||
spanStatusCodeError: 2 as any,
|
||||
}, carrier);
|
||||
|
||||
expect(inject).toHaveBeenCalledWith(activeContext, carrier);
|
||||
});
|
||||
|
||||
test('starts a new root trace with a link by default', () => {
|
||||
const extract = vi.fn(() => extractedContext);
|
||||
const getSpanContext = vi.fn(() => sourceSpanContext);
|
||||
|
||||
const result = getQueueSpanContext(jobData(), {
|
||||
rootContext,
|
||||
propagation: { inject: vi.fn(), extract },
|
||||
trace: { getSpanContext },
|
||||
mode: 'link',
|
||||
});
|
||||
|
||||
expect(extract).toHaveBeenCalledWith(rootContext, jobData().__misskeyTraceContext);
|
||||
expect(result).toEqual({
|
||||
options: {
|
||||
root: true,
|
||||
links: [{ context: sourceSpanContext }],
|
||||
},
|
||||
parentContext: rootContext,
|
||||
});
|
||||
expect(result?.parentContext).toBe(rootContext);
|
||||
expect(getSpanContext).toHaveBeenCalledWith(extractedContext);
|
||||
});
|
||||
|
||||
test('uses the extracted context as the parent when parent mode is selected', () => {
|
||||
const result = getQueueSpanContext(jobData(), {
|
||||
rootContext,
|
||||
propagation: { inject: vi.fn(), extract: () => extractedContext },
|
||||
trace: { getSpanContext: () => sourceSpanContext },
|
||||
mode: 'parent',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
options: {},
|
||||
parentContext: extractedContext,
|
||||
});
|
||||
expect(result?.parentContext).toBe(extractedContext);
|
||||
});
|
||||
|
||||
test('ignores malformed or missing carriers', () => {
|
||||
const extract = vi.fn(() => extractedContext);
|
||||
const deps = {
|
||||
rootContext,
|
||||
propagation: { inject: vi.fn(), extract },
|
||||
trace: { getSpanContext: () => sourceSpanContext },
|
||||
mode: 'link' as const,
|
||||
};
|
||||
|
||||
expect(getQueueSpanContext({}, deps)).toBeUndefined();
|
||||
expect(getQueueSpanContext({ __misskeyTraceContext: { traceparent: 1 } }, deps)).toBeUndefined();
|
||||
expect(extract).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('defaults to link mode and rejects invalid configuration', () => {
|
||||
expect(getQueueTraceContextMode(undefined)).toBe('link');
|
||||
expect(getQueueTraceContextMode('parent')).toBe('parent');
|
||||
expect(() => getQueueTraceContextMode('children')).toThrow('otelForBackend.jobTraceContextMode');
|
||||
});
|
||||
});
|
||||
@@ -4,13 +4,13 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { runQueueJob } from '@/queue/queue-job-runner.js';
|
||||
import { runQueueJobWithTraceContext } from '@/queue/queue-job-runner.js';
|
||||
import { TelemetryService } from '@/core/telemetry/TelemetryService.js';
|
||||
|
||||
describe('runQueueJob', () => {
|
||||
describe('runQueueJobWithTraceContext', () => {
|
||||
test('returns the processor result without invoking the error handler', async () => {
|
||||
let spanActive = false;
|
||||
const startSpan = vi.fn(<T>(_name: string, fn: () => T): T => {
|
||||
const startSpanWithTraceContext = vi.fn(<T>(_name: string, _jobData: object, fn: () => T): T => {
|
||||
spanActive = true;
|
||||
const result = fn();
|
||||
if (result instanceof Promise) return result.finally(() => { spanActive = false; }) as T;
|
||||
@@ -18,11 +18,11 @@ describe('runQueueJob', () => {
|
||||
return result;
|
||||
});
|
||||
const telemetryService = {
|
||||
startSpan,
|
||||
startSpanWithTraceContext,
|
||||
} as unknown as TelemetryService;
|
||||
const onError = vi.fn();
|
||||
|
||||
await expect(runQueueJob(telemetryService, 'Queue: test', () => 'ok', onError)).resolves.toBe('ok');
|
||||
await expect(runQueueJobWithTraceContext(telemetryService, 'Queue: test', {}, () => 'ok', onError)).resolves.toBe('ok');
|
||||
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
expect(spanActive).toBe(false);
|
||||
@@ -30,7 +30,7 @@ describe('runQueueJob', () => {
|
||||
|
||||
test('handles failures while the processor span is active and rethrows the original error', async () => {
|
||||
let spanActive = false;
|
||||
const startSpan = vi.fn(<T>(_name: string, fn: () => T): T => {
|
||||
const startSpanWithTraceContext = vi.fn(<T>(_name: string, _jobData: object, fn: () => T): T => {
|
||||
spanActive = true;
|
||||
const result = fn();
|
||||
if (result instanceof Promise) return result.finally(() => { spanActive = false; }) as T;
|
||||
@@ -38,7 +38,7 @@ describe('runQueueJob', () => {
|
||||
return result;
|
||||
});
|
||||
const telemetryService = {
|
||||
startSpan,
|
||||
startSpanWithTraceContext,
|
||||
} as unknown as TelemetryService;
|
||||
const onError = vi.fn((error: Error) => {
|
||||
expect(spanActive).toBe(true);
|
||||
@@ -46,7 +46,7 @@ describe('runQueueJob', () => {
|
||||
});
|
||||
const originalError = new Error('failed');
|
||||
|
||||
await expect(runQueueJob(telemetryService, 'Queue: test', async () => {
|
||||
await expect(runQueueJobWithTraceContext(telemetryService, 'Queue: test', {}, async () => {
|
||||
throw originalError;
|
||||
}, onError)).rejects.toBe(originalError);
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { shouldRegisterHttpServerInstrumentation, registerHttpServerInstrumentation } from '@/server/http-server-instrumentation.js';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
plugin: vi.fn(),
|
||||
instrumentation: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@fastify/otel', () => ({
|
||||
FastifyOtelInstrumentation: class {
|
||||
public plugin = mocks.plugin;
|
||||
|
||||
public constructor(options: unknown) {
|
||||
mocks.instrumentation(options);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
describe('http-server-instrumentation', () => {
|
||||
test('registers Fastify instrumentation when only OpenTelemetry is configured', async () => {
|
||||
const plugin = vi.fn();
|
||||
const fastify = { register: vi.fn().mockResolvedValue(undefined) };
|
||||
mocks.plugin.mockReturnValue(plugin);
|
||||
|
||||
await registerHttpServerInstrumentation(fastify as any, { otelForBackend: {} } as any);
|
||||
|
||||
expect(mocks.instrumentation).toHaveBeenCalledTimes(1);
|
||||
expect(fastify.register).toHaveBeenCalledWith(plugin);
|
||||
|
||||
const requestHook = mocks.instrumentation.mock.calls[0][0].requestHook;
|
||||
const span = { updateName: vi.fn() };
|
||||
requestHook(span, { method: 'POST', routeOptions: { url: '/notes/create' } });
|
||||
expect(span.updateName).toHaveBeenCalledWith('POST /notes/create');
|
||||
});
|
||||
|
||||
test('does not register duplicate request instrumentation with Sentry', async () => {
|
||||
const fastify = { register: vi.fn() };
|
||||
|
||||
await registerHttpServerInstrumentation(fastify as any, { otelForBackend: {}, sentryForBackend: {} } as any);
|
||||
|
||||
expect(fastify.register).not.toHaveBeenCalled();
|
||||
expect(shouldRegisterHttpServerInstrumentation({ otelForBackend: {}, sentryForBackend: {} } as any)).toBe(false);
|
||||
});
|
||||
|
||||
test('does not register instrumentation without OpenTelemetry', () => {
|
||||
expect(shouldRegisterHttpServerInstrumentation({} as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { installDatabaseInstrumentation, installInstrumentation } from '@/core/telemetry/database-instrumentation.js';
|
||||
|
||||
describe('database-instrumentation', () => {
|
||||
test('does not install PostgreSQL instrumentation when disabled', async () => {
|
||||
const uninstall = await installDatabaseInstrumentation({} as any, {
|
||||
capturePgSpans: false,
|
||||
capturePgStatement: false,
|
||||
capturePgConnectionSpans: false,
|
||||
});
|
||||
|
||||
expect(uninstall).toBeTypeOf('function');
|
||||
expect(() => uninstall()).not.toThrow();
|
||||
});
|
||||
|
||||
test('registers pg instrumentation with the active provider', () => {
|
||||
const provider = {};
|
||||
const pg = { setTracerProvider: vi.fn(), enable: vi.fn(), disable: vi.fn() };
|
||||
const config = vi.fn();
|
||||
const PgInstrumentation = class {
|
||||
public constructor(options: unknown) {
|
||||
config(options);
|
||||
return pg as any;
|
||||
}
|
||||
};
|
||||
|
||||
const uninstall = installInstrumentation(provider as any, {
|
||||
PgInstrumentation: PgInstrumentation as any,
|
||||
}, {
|
||||
capturePgStatement: false,
|
||||
capturePgConnectionSpans: false,
|
||||
});
|
||||
|
||||
expect(pg.setTracerProvider).toHaveBeenCalledWith(provider);
|
||||
expect(pg.enable).toHaveBeenCalledOnce();
|
||||
expect(config).toHaveBeenCalledWith(expect.objectContaining({
|
||||
enhancedDatabaseReporting: false,
|
||||
requireParentSpan: true,
|
||||
ignoreConnectSpans: true,
|
||||
requestHook: expect.any(Function),
|
||||
}));
|
||||
|
||||
const span = { setAttribute: vi.fn() };
|
||||
(config.mock.calls[0][0] as { requestHook: (span: any) => void }).requestHook(span);
|
||||
expect(span.setAttribute).toHaveBeenCalledWith('db.statement', '[REDACTED]');
|
||||
expect(span.setAttribute).toHaveBeenCalledWith('db.query.text', '[REDACTED]');
|
||||
|
||||
uninstall();
|
||||
|
||||
expect(pg.disable).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('keeps SQL statement attributes when explicitly enabled', () => {
|
||||
const pg = { setTracerProvider: vi.fn(), enable: vi.fn(), disable: vi.fn() };
|
||||
const config = vi.fn();
|
||||
|
||||
installInstrumentation({} as any, {
|
||||
PgInstrumentation: class {
|
||||
public constructor(options: unknown) {
|
||||
config(options);
|
||||
return pg as any;
|
||||
}
|
||||
} as any,
|
||||
}, {
|
||||
capturePgStatement: true,
|
||||
capturePgConnectionSpans: false,
|
||||
});
|
||||
|
||||
expect(config.mock.calls[0][0]).not.toHaveProperty('requestHook');
|
||||
});
|
||||
|
||||
test('enables connection spans when explicitly configured', () => {
|
||||
const pg = { setTracerProvider: vi.fn(), enable: vi.fn(), disable: vi.fn() };
|
||||
const config = vi.fn();
|
||||
|
||||
installInstrumentation({} as any, {
|
||||
PgInstrumentation: class {
|
||||
public constructor(options: unknown) {
|
||||
config(options);
|
||||
return pg as any;
|
||||
}
|
||||
} as any,
|
||||
}, {
|
||||
capturePgStatement: false,
|
||||
capturePgConnectionSpans: true,
|
||||
});
|
||||
|
||||
expect(config).toHaveBeenCalledWith(expect.objectContaining({
|
||||
ignoreConnectSpans: false,
|
||||
}));
|
||||
});
|
||||
|
||||
test('cleans up both instrumentations when initialization fails', () => {
|
||||
const pg = { setTracerProvider: vi.fn(), enable: vi.fn(), disable: vi.fn() };
|
||||
pg.enable.mockImplementation(() => { throw new Error('failed'); });
|
||||
|
||||
expect(() => installInstrumentation({} as any, {
|
||||
PgInstrumentation: class { public constructor() { return pg as any; } } as any,
|
||||
})).toThrow('failed');
|
||||
|
||||
expect(pg.disable).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { SpanKind, SpanStatusCode } from '@opentelemetry/api';
|
||||
import { createRedisInstrumentation } from '@/core/telemetry/redis-instrumentation.js';
|
||||
|
||||
describe('redis-instrumentation', () => {
|
||||
test('creates and completes a span for an ioredis command', () => {
|
||||
let subscribers: any;
|
||||
const unsubscribe = vi.fn();
|
||||
const span = { end: vi.fn(), recordException: vi.fn(), setStatus: vi.fn(), setAttribute: vi.fn() };
|
||||
const tracer = { startSpan: vi.fn(() => span) };
|
||||
const uninstall = createRedisInstrumentation({
|
||||
tracingChannel: () => ({ subscribe: (value) => { subscribers = value; }, unsubscribe }),
|
||||
tracer: tracer as any,
|
||||
getActiveSpan: () => ({}) as any,
|
||||
spanKindClient: SpanKind.CLIENT,
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
}, { captureCommandSpans: true });
|
||||
const command = { command: 'get', args: ['key'], database: 0, serverAddress: 'redis', serverPort: 6379 };
|
||||
|
||||
subscribers.start(command);
|
||||
subscribers.asyncEnd(command);
|
||||
|
||||
expect(tracer.startSpan).toHaveBeenCalledWith('get', expect.objectContaining({
|
||||
kind: SpanKind.CLIENT,
|
||||
attributes: expect.objectContaining({
|
||||
'db.system.name': 'redis',
|
||||
'db.operation.name': 'get',
|
||||
'server.address': 'redis',
|
||||
'server.port': 6379,
|
||||
}),
|
||||
}));
|
||||
expect(span.end).toHaveBeenCalledOnce();
|
||||
|
||||
uninstall();
|
||||
expect(unsubscribe).toHaveBeenCalledWith(subscribers);
|
||||
});
|
||||
|
||||
test('records rejected Redis commands as errors', () => {
|
||||
let subscribers: any;
|
||||
const span = { end: vi.fn(), recordException: vi.fn(), setStatus: vi.fn(), setAttribute: vi.fn() };
|
||||
createRedisInstrumentation({
|
||||
tracingChannel: () => ({ subscribe: (value) => { subscribers = value; }, unsubscribe: vi.fn() }),
|
||||
tracer: { startSpan: vi.fn(() => span) } as any,
|
||||
getActiveSpan: () => ({}) as any,
|
||||
spanKindClient: SpanKind.CLIENT,
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
}, { captureCommandSpans: true });
|
||||
const command = { command: 'get', args: ['key'], database: 0, serverAddress: 'redis', serverPort: undefined };
|
||||
const error = Object.assign(new Error('ERR Redis failed'), { code: 'ERR' });
|
||||
|
||||
subscribers.start(command);
|
||||
Object.assign(command, { error });
|
||||
subscribers.error(command);
|
||||
subscribers.asyncEnd(command);
|
||||
|
||||
expect(span.recordException).toHaveBeenCalledWith(error);
|
||||
expect(span.recordException).toHaveBeenCalledOnce();
|
||||
expect(span.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR, message: 'ERR Redis failed' });
|
||||
expect(span.setAttribute).toHaveBeenCalledWith('error.type', 'ERR');
|
||||
expect(span.setAttribute).toHaveBeenCalledWith('db.response.status_code', 'ERR');
|
||||
expect(span.end).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('does not create a root span when no parent span is active', () => {
|
||||
let subscribers: any;
|
||||
const tracer = { startSpan: vi.fn() };
|
||||
createRedisInstrumentation({
|
||||
tracingChannel: () => ({ subscribe: (value) => { subscribers = value; }, unsubscribe: vi.fn() }),
|
||||
tracer: tracer as any,
|
||||
getActiveSpan: () => undefined,
|
||||
spanKindClient: SpanKind.CLIENT,
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
}, { captureCommandSpans: true });
|
||||
|
||||
subscribers.start({ command: 'get', args: ['key'], database: 0, serverAddress: 'redis', serverPort: 6379 });
|
||||
|
||||
expect(tracer.startSpan).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('creates a root span when explicitly enabled', () => {
|
||||
let subscribers: any;
|
||||
const span = { end: vi.fn(), recordException: vi.fn(), setStatus: vi.fn(), setAttribute: vi.fn() };
|
||||
const tracer = { startSpan: vi.fn(() => span) };
|
||||
createRedisInstrumentation({
|
||||
tracingChannel: () => ({ subscribe: (value) => { subscribers = value; }, unsubscribe: vi.fn() }),
|
||||
tracer: tracer as any,
|
||||
getActiveSpan: () => undefined,
|
||||
spanKindClient: SpanKind.CLIENT,
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
}, { captureCommandSpans: true, requireParentSpan: false });
|
||||
|
||||
const command = { command: 'get', args: ['key'], database: 0, serverAddress: 'redis', serverPort: 6379 };
|
||||
subscribers.start(command);
|
||||
subscribers.asyncEnd(command);
|
||||
|
||||
expect(tracer.startSpan).toHaveBeenCalledOnce();
|
||||
expect(span.end).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('records connection spans only when explicitly enabled', () => {
|
||||
const subscribers = new Map<string, any>();
|
||||
const unsubscribe = vi.fn();
|
||||
const span = { end: vi.fn(), recordException: vi.fn(), setStatus: vi.fn(), setAttribute: vi.fn() };
|
||||
const tracer = { startSpan: vi.fn(() => span) };
|
||||
const uninstall = createRedisInstrumentation({
|
||||
tracingChannel: (name) => ({ subscribe: (value) => { subscribers.set(name, value); }, unsubscribe }),
|
||||
tracer: tracer as any,
|
||||
getActiveSpan: () => undefined,
|
||||
spanKindClient: SpanKind.CLIENT,
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
}, { captureConnectionSpans: true });
|
||||
|
||||
const connection = { serverAddress: 'redis', serverPort: 6379 };
|
||||
subscribers.get('ioredis:connect').start(connection);
|
||||
subscribers.get('ioredis:connect').asyncEnd(connection);
|
||||
|
||||
expect(tracer.startSpan).toHaveBeenCalledWith('connect', expect.objectContaining({
|
||||
kind: SpanKind.CLIENT,
|
||||
attributes: expect.objectContaining({
|
||||
'db.operation.name': 'connect',
|
||||
'server.address': 'redis',
|
||||
'server.port': 6379,
|
||||
}),
|
||||
}));
|
||||
expect(span.end).toHaveBeenCalledOnce();
|
||||
|
||||
uninstall();
|
||||
expect(unsubscribe).toHaveBeenCalledWith(subscribers.get('ioredis:connect'));
|
||||
});
|
||||
|
||||
test('does not subscribe to Redis command diagnostics unless explicitly enabled', () => {
|
||||
const tracingChannel = vi.fn();
|
||||
createRedisInstrumentation({
|
||||
tracingChannel,
|
||||
tracer: { startSpan: vi.fn() } as any,
|
||||
getActiveSpan: () => undefined,
|
||||
spanKindClient: SpanKind.CLIENT,
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
});
|
||||
|
||||
expect(tracingChannel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('omits the database attribute when diagnostics context has no database', () => {
|
||||
let subscribers: any;
|
||||
const span = { end: vi.fn(), recordException: vi.fn(), setStatus: vi.fn(), setAttribute: vi.fn() };
|
||||
const tracer = { startSpan: vi.fn(() => span) };
|
||||
createRedisInstrumentation({
|
||||
tracingChannel: () => ({ subscribe: (value) => { subscribers = value; }, unsubscribe: vi.fn() }),
|
||||
tracer: tracer as any,
|
||||
getActiveSpan: () => ({}) as any,
|
||||
spanKindClient: SpanKind.CLIENT,
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
}, { captureCommandSpans: true });
|
||||
|
||||
subscribers.start({ command: 'get', args: ['key'], database: undefined, serverAddress: 'redis', serverPort: 6379 });
|
||||
|
||||
expect(tracer.startSpan).toHaveBeenCalledWith('get', expect.objectContaining({
|
||||
attributes: expect.not.objectContaining({
|
||||
'db.namespace': expect.anything(),
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
test('does not propagate errors from Redis tracing subscribers', () => {
|
||||
const subscribers = new Map<string, any>();
|
||||
const reportError = vi.fn();
|
||||
const endError = new Error('span end failed');
|
||||
const startError = new Error('span creation failed');
|
||||
const span = {
|
||||
end: vi.fn(() => { throw endError; }),
|
||||
recordException: vi.fn(),
|
||||
setStatus: vi.fn(),
|
||||
setAttribute: vi.fn(),
|
||||
};
|
||||
const tracer = {
|
||||
startSpan: vi.fn()
|
||||
.mockReturnValueOnce(span)
|
||||
.mockImplementationOnce(() => { throw startError; }),
|
||||
};
|
||||
createRedisInstrumentation({
|
||||
tracingChannel: (name) => ({
|
||||
subscribe: (value) => { subscribers.set(name, value); },
|
||||
unsubscribe: vi.fn(),
|
||||
}),
|
||||
tracer: tracer as any,
|
||||
getActiveSpan: () => ({}) as any,
|
||||
spanKindClient: SpanKind.CLIENT,
|
||||
spanStatusCodeError: SpanStatusCode.ERROR,
|
||||
reportError,
|
||||
}, {
|
||||
captureCommandSpans: true,
|
||||
captureConnectionSpans: true,
|
||||
});
|
||||
const command = { command: 'get', args: ['key'], database: 0, serverAddress: 'redis', serverPort: 6379 };
|
||||
|
||||
subscribers.get('ioredis:command').start(command);
|
||||
expect(() => subscribers.get('ioredis:command').asyncEnd(command)).not.toThrow();
|
||||
expect(() => subscribers.get('ioredis:connect').start({ serverAddress: 'redis', serverPort: 6379 })).not.toThrow();
|
||||
|
||||
expect(reportError).toHaveBeenCalledWith(endError);
|
||||
expect(reportError).toHaveBeenCalledWith(startError);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,8 @@ import type { Config } from '@/config.js';
|
||||
const mocks = vi.hoisted(() => {
|
||||
return {
|
||||
sentryCreate: vi.fn(),
|
||||
sentryCreateWithOtlpExport: vi.fn(),
|
||||
otelCreate: vi.fn(),
|
||||
setLogTraceContextProvider: vi.fn(),
|
||||
};
|
||||
});
|
||||
@@ -20,6 +22,13 @@ vi.mock('@/logging/logging-runtime.js', () => ({
|
||||
vi.mock('@/core/telemetry/adapters/SentryTelemetryAdapter.js', () => ({
|
||||
SentryTelemetryAdapter: {
|
||||
create: mocks.sentryCreate,
|
||||
createWithOtlpExport: mocks.sentryCreateWithOtlpExport,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/core/telemetry/adapters/OpenTelemetryAdapter.js', () => ({
|
||||
OpenTelemetryAdapter: {
|
||||
create: mocks.otelCreate,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -34,37 +43,44 @@ describe('telemetry-registry', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mocks.sentryCreate.mockReset();
|
||||
mocks.sentryCreateWithOtlpExport.mockReset();
|
||||
mocks.otelCreate.mockReset();
|
||||
mocks.setLogTraceContextProvider.mockReset();
|
||||
mocks.sentryCreate.mockResolvedValue({ shutdown: vi.fn(), captureMessage: vi.fn(), startSpan: vi.fn() });
|
||||
mocks.sentryCreateWithOtlpExport.mockResolvedValue({ shutdown: vi.fn(), captureMessage: vi.fn(), startSpan: vi.fn() });
|
||||
mocks.otelCreate.mockResolvedValue({ shutdown: vi.fn(), captureMessage: vi.fn(), startSpan: vi.fn() });
|
||||
});
|
||||
|
||||
test('does not initialize an adapter when Sentry is not configured', async () => {
|
||||
test('uses OpenTelemetryAdapter when only otelForBackend is configured', async () => {
|
||||
const { initTelemetry } = await import('@/core/telemetry/telemetry-registry.js');
|
||||
const otelForBackend = { endpoint: 'http://collector:4318/v1/traces' };
|
||||
|
||||
await initTelemetry(config({}));
|
||||
await initTelemetry(config({ otelForBackend }));
|
||||
|
||||
expect(mocks.otelCreate).toHaveBeenCalledWith({
|
||||
...otelForBackend,
|
||||
serviceVersion: '2026.1.0',
|
||||
});
|
||||
expect(mocks.sentryCreate).not.toHaveBeenCalled();
|
||||
expect(mocks.setLogTraceContextProvider).not.toHaveBeenCalled();
|
||||
expect(mocks.sentryCreateWithOtlpExport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('initializes Sentry and registers its trace context provider', async () => {
|
||||
test('registers the adapter trace context provider after telemetry initialization', async () => {
|
||||
const { initTelemetry } = await import('@/core/telemetry/telemetry-registry.js');
|
||||
const sentryForBackend = { options: {}, enableNodeProfiling: false };
|
||||
const getActiveTraceContext = vi.fn(() => ({
|
||||
traceId: '0123456789abcdef0123456789abcdef',
|
||||
spanId: '0123456789abcdef',
|
||||
traceFlags: 0,
|
||||
}));
|
||||
mocks.sentryCreate.mockResolvedValue({
|
||||
mocks.otelCreate.mockResolvedValue({
|
||||
shutdown: vi.fn(),
|
||||
captureMessage: vi.fn(),
|
||||
startSpan: vi.fn(),
|
||||
getActiveTraceContext,
|
||||
});
|
||||
|
||||
await initTelemetry(config({ sentryForBackend }));
|
||||
await initTelemetry(config({ otelForBackend: { endpoint: 'http://collector:4318/v1/traces' } }));
|
||||
|
||||
expect(mocks.sentryCreate).toHaveBeenCalledWith(sentryForBackend);
|
||||
expect(mocks.setLogTraceContextProvider).toHaveBeenCalledWith(expect.any(Function));
|
||||
const provider = mocks.setLogTraceContextProvider.mock.calls[0][0] as () => unknown;
|
||||
expect(provider()).toEqual({
|
||||
@@ -75,6 +91,21 @@ describe('telemetry-registry', () => {
|
||||
expect(getActiveTraceContext).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('adds OTLP export to the Sentry provider when both Sentry and OTel are configured', async () => {
|
||||
const { initTelemetry } = await import('@/core/telemetry/telemetry-registry.js');
|
||||
const sentryForBackend = { options: {}, enableNodeProfiling: false };
|
||||
const otelForBackend = { endpoint: 'http://collector:4318/v1/traces' };
|
||||
|
||||
await initTelemetry(config({ sentryForBackend, otelForBackend }));
|
||||
|
||||
expect(mocks.sentryCreateWithOtlpExport).toHaveBeenCalledWith(sentryForBackend, {
|
||||
...otelForBackend,
|
||||
serviceVersion: '2026.1.0',
|
||||
});
|
||||
expect(mocks.sentryCreate).not.toHaveBeenCalled();
|
||||
expect(mocks.otelCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('startSpan runs fn directly when no adapter is registered', async () => {
|
||||
const { startSpan } = await import('@/core/telemetry/telemetry-registry.js');
|
||||
|
||||
@@ -83,32 +114,89 @@ describe('telemetry-registry', () => {
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('startSpan delegates to the Sentry adapter', async () => {
|
||||
test('startSpan delegates directly to the single registered adapter without extra wrapping', async () => {
|
||||
const { initTelemetry, startSpan } = await import('@/core/telemetry/telemetry-registry.js');
|
||||
const otelForBackend = { endpoint: 'http://collector:4318/v1/traces' };
|
||||
const adapterStartSpan = vi.fn((_name: string, fn: () => string) => fn());
|
||||
mocks.sentryCreate.mockResolvedValue({ shutdown: vi.fn(), captureMessage: vi.fn(), startSpan: adapterStartSpan });
|
||||
mocks.otelCreate.mockResolvedValue({ shutdown: vi.fn(), captureMessage: vi.fn(), startSpan: adapterStartSpan });
|
||||
|
||||
await initTelemetry(config({ sentryForBackend: { options: {}, enableNodeProfiling: false } }));
|
||||
await initTelemetry(config({ otelForBackend }));
|
||||
|
||||
const fn = vi.fn().mockReturnValue('result');
|
||||
expect(startSpan('test', fn)).toBe('result');
|
||||
expect(adapterStartSpan).toHaveBeenCalledWith('test', fn);
|
||||
});
|
||||
|
||||
test('shutdownTelemetry waits for all registered adapters even when one rejects', async () => {
|
||||
const { initTelemetry, shutdownTelemetry } = await import('@/core/telemetry/telemetry-registry.js');
|
||||
const firstShutdown = vi.fn().mockRejectedValue(new Error('first failed'));
|
||||
const secondShutdown = vi.fn().mockResolvedValue(undefined);
|
||||
mocks.sentryCreate
|
||||
.mockResolvedValueOnce({ shutdown: firstShutdown, captureMessage: vi.fn(), startSpan: vi.fn() })
|
||||
.mockResolvedValueOnce({ shutdown: secondShutdown, captureMessage: vi.fn(), startSpan: vi.fn() });
|
||||
test('startSpanWithTraceContext executes an undefined-returning callback only once', async () => {
|
||||
const { initTelemetry, startSpanWithTraceContext } = await import('@/core/telemetry/telemetry-registry.js');
|
||||
const otelForBackend = { endpoint: 'http://collector:4318/v1/traces' };
|
||||
const adapterStartSpan = vi.fn((_name: string, fn: () => undefined) => fn());
|
||||
const adapterStartSpanWithTraceContext = vi.fn((_name: string, _jobData: object, fn: () => undefined) => fn());
|
||||
mocks.otelCreate.mockResolvedValue({
|
||||
shutdown: vi.fn(),
|
||||
captureMessage: vi.fn(),
|
||||
startSpan: adapterStartSpan,
|
||||
startSpanWithTraceContext: adapterStartSpanWithTraceContext,
|
||||
});
|
||||
|
||||
const sentryForBackend = { options: {}, enableNodeProfiling: false };
|
||||
await initTelemetry(config({ sentryForBackend }));
|
||||
await initTelemetry(config({ sentryForBackend }));
|
||||
await initTelemetry(config({ otelForBackend }));
|
||||
|
||||
const jobData = { id: 'job' };
|
||||
const fn = vi.fn(() => undefined);
|
||||
expect(startSpanWithTraceContext('test', jobData, fn)).toBeUndefined();
|
||||
expect(adapterStartSpanWithTraceContext).toHaveBeenCalledWith('test', jobData, fn);
|
||||
expect(adapterStartSpan).not.toHaveBeenCalled();
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('startSpan wraps work through multiple registered adapters in order for future adapter combinations', async () => {
|
||||
const { initTelemetry, startSpan } = await import('@/core/telemetry/telemetry-registry.js');
|
||||
const calls: string[] = [];
|
||||
mocks.sentryCreate.mockResolvedValue({
|
||||
shutdown: vi.fn(),
|
||||
captureMessage: vi.fn(),
|
||||
startSpan: vi.fn((_name: string, fn: () => string) => {
|
||||
calls.push('sentry:start');
|
||||
const result = fn();
|
||||
calls.push('sentry:end');
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
mocks.otelCreate.mockResolvedValue({
|
||||
shutdown: vi.fn(),
|
||||
captureMessage: vi.fn(),
|
||||
startSpan: vi.fn((_name: string, fn: () => string) => {
|
||||
calls.push('otel:start');
|
||||
const result = fn();
|
||||
calls.push('otel:end');
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
|
||||
await initTelemetry(config({ sentryForBackend: { options: {}, enableNodeProfiling: false } }));
|
||||
await initTelemetry(config({ otelForBackend: { endpoint: 'http://collector:4318/v1/traces' } }));
|
||||
|
||||
const fn = vi.fn(() => {
|
||||
calls.push('work');
|
||||
return 'result';
|
||||
});
|
||||
|
||||
expect(startSpan('test', fn)).toBe('result');
|
||||
expect(calls).toEqual(['sentry:start', 'otel:start', 'work', 'otel:end', 'sentry:end']);
|
||||
});
|
||||
|
||||
test('shutdownTelemetry waits for every adapter even when one shutdown rejects', async () => {
|
||||
const { initTelemetry, shutdownTelemetry } = await import('@/core/telemetry/telemetry-registry.js');
|
||||
const sentryShutdown = vi.fn().mockRejectedValue(new Error('sentry failed'));
|
||||
const otelShutdown = vi.fn().mockResolvedValue(undefined);
|
||||
mocks.sentryCreate.mockResolvedValue({ shutdown: sentryShutdown, captureMessage: vi.fn(), startSpan: vi.fn() });
|
||||
mocks.otelCreate.mockResolvedValue({ shutdown: otelShutdown, captureMessage: vi.fn(), startSpan: vi.fn() });
|
||||
|
||||
await initTelemetry(config({ sentryForBackend: { options: {}, enableNodeProfiling: false } }));
|
||||
await initTelemetry(config({ otelForBackend: { endpoint: 'http://collector:4318/v1/traces' } }));
|
||||
|
||||
await expect(shutdownTelemetry()).resolves.toBeUndefined();
|
||||
expect(firstShutdown).toHaveBeenCalledTimes(1);
|
||||
expect(secondShutdown).toHaveBeenCalledTimes(1);
|
||||
expect(sentryShutdown).toHaveBeenCalledTimes(1);
|
||||
expect(otelShutdown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# frontend用Misskey Worldエンジン
|
||||
|
||||
エンジンはWeb Worker内で動作し、ほぼすべてのMisskey Webの機能は使えないため、意図しないそれらへの参照/依存が原理的に発生しないように別パッケージとする
|
||||
|
||||
ただしヘッドレス動作することは(今のところ)意図していない
|
||||
@@ -1,28 +0,0 @@
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import sharedConfig from '../shared/eslint.config.js';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default [
|
||||
...sharedConfig,
|
||||
{
|
||||
ignores: [
|
||||
'**/node_modules',
|
||||
'built',
|
||||
'coverage',
|
||||
'jest.config.ts',
|
||||
'test',
|
||||
'test-d',
|
||||
],
|
||||
},
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: tsParser,
|
||||
project: ['./tsconfig.json'],
|
||||
sourceType: 'module',
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"type": "module",
|
||||
"name": "frontend-misskey-world-engine",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"eslint": "eslint './**/*.{js,jsx,ts,tsx}'",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"lint": "pnpm typecheck && pnpm eslint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/seedrandom": "3.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "8.59.2",
|
||||
"@typescript-eslint/parser": "8.59.2",
|
||||
"esbuild": "0.28.0",
|
||||
"execa": "9.6.1",
|
||||
"nodemon": "3.1.14",
|
||||
"throttle-debounce": "5.0.2",
|
||||
"@types/tinycolor2": "1.4.6"
|
||||
},
|
||||
"files": [
|
||||
"built"
|
||||
],
|
||||
"dependencies": {
|
||||
"@babylonjs/core": "9.19.0",
|
||||
"@babylonjs/inspector": "9.19.0",
|
||||
"@babylonjs/loaders": "9.19.0",
|
||||
"@babylonjs/materials": "9.19.0",
|
||||
"@types/throttle-debounce": "5.0.2",
|
||||
"eventemitter3": "5.0.4",
|
||||
"seedrandom": "3.0.5",
|
||||
"tinycolor2": "1.6.0",
|
||||
"hls.js": "1.6.16"
|
||||
}
|
||||
}
|
||||
@@ -1,129 +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 babylonEngine: BABYLON.WebGPUEngine;
|
||||
public scene: BABYLON.Scene;
|
||||
abstract sr: BABYLON.SnapshotRenderingHelper;
|
||||
abstract lightContainer: BABYLON.ClusteredLightContainer;
|
||||
abstract getEnvMap(): BABYLON.CubeTexture | null;
|
||||
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: {
|
||||
babylonEngine: BABYLON.WebGPUEngine;
|
||||
fps: number | null;
|
||||
}) {
|
||||
super();
|
||||
|
||||
this.fps = options.fps;
|
||||
|
||||
this.babylonEngine = options.babylonEngine;
|
||||
// doNotHandleContextLostがtrueだとそもそも呼ばれない
|
||||
//babylonEngine.onContextLostObservable.add(() => {
|
||||
// os.alert({
|
||||
// type: 'error',
|
||||
// title: i18n.ts.somethingHappened,
|
||||
// text: i18n.ts._miWorld.crushed_description,
|
||||
// });
|
||||
//});
|
||||
this.babylonEngine._device.lost.then((info) => { // TODO: babylonEngineの内部プロパティに依存しない方法をforumで聞く
|
||||
this.ev('contextlost', { reason: info.reason, message: info.message }); // transferableじゃないデータが含まれている可能性も考慮してinfoそのままは送らない
|
||||
});
|
||||
|
||||
this.scene = new BABYLON.Scene(this.babylonEngine);
|
||||
}
|
||||
|
||||
private currentRafId: number | null = null;
|
||||
|
||||
protected startRenderLoop() {
|
||||
if (this.fps == null) {
|
||||
this.babylonEngine.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.babylonEngine.beginFrame();
|
||||
this.scene.render();
|
||||
this.babylonEngine.endFrame();
|
||||
};
|
||||
|
||||
// workerで実行される可能性がある
|
||||
this.currentRafId = requestAnimationFrame(renderLoop);
|
||||
}
|
||||
}
|
||||
|
||||
public pauseRender() { // TODO: srと同じく参照カウント方式にした方が便利そう
|
||||
this.babylonEngine.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.babylonEngine, this.scene.activeCamera!, { precision: 1 });
|
||||
}
|
||||
|
||||
public abstract resize(): void;
|
||||
|
||||
public destroy() {
|
||||
this.babylonEngine.stopRenderLoop();
|
||||
if (this.currentRafId != null) {
|
||||
// workerで実行される可能性がある
|
||||
cancelAnimationFrame(this.currentRafId);
|
||||
this.currentRafId = null;
|
||||
}
|
||||
this.babylonEngine.dispose();
|
||||
this.scene.dispose();
|
||||
this.disposed = true;
|
||||
}
|
||||
}
|
||||
@@ -1,171 +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 { randomRange, Timer } from './utility.js';
|
||||
import type { EngineBase } from './EngineBase.js';
|
||||
|
||||
export class Firework {
|
||||
private engine: EngineBase<any>;
|
||||
private timer: Timer = new Timer();
|
||||
private texturePatterns = [
|
||||
{ path: '/client-assets/world/other/firework/flare-glow-1.png', color: new BABYLON.Color3(0.0, 1.0, 0.0) },
|
||||
{ path: '/client-assets/world/other/firework/flare-glow-2.png', color: new BABYLON.Color3(0.0, 1.0, 1.0) },
|
||||
{ path: '/client-assets/world/other/firework/flare-glow-3.png', color: new BABYLON.Color3(0.0, 0.0, 1.0) },
|
||||
{ path: '/client-assets/world/other/firework/flare-glow-4.png', color: new BABYLON.Color3(1.0, 0.0, 1.0) },
|
||||
{ path: '/client-assets/world/other/firework/flare-glow-5.png', color: new BABYLON.Color3(1.0, 0.0, 0.0) },
|
||||
{ path: '/client-assets/world/other/firework/flare-glow-6.png', color: new BABYLON.Color3(1.0, 0.5, 0.0) },
|
||||
{ path: '/client-assets/world/other/firework/flare-glow-7.png', color: new BABYLON.Color3(1.0, 1.0, 0.0) },
|
||||
{ path: '/client-assets/world/other/firework/flare-glow-8.png', color: new BABYLON.Color3(0.5, 1.0, 0.0) },
|
||||
];
|
||||
|
||||
constructor(engine: EngineBase<any>) {
|
||||
this.engine = engine;
|
||||
}
|
||||
|
||||
public launch(options: {
|
||||
position: [number, number, number];
|
||||
}) {
|
||||
this.engine.sr.disableSnapshotRendering();
|
||||
|
||||
const texturePattern = this.texturePatterns[Math.floor(Math.random() * this.texturePatterns.length)];
|
||||
const texture = new BABYLON.Texture(texturePattern.path, this.engine.scene);
|
||||
const textureScaleFactor = 3;
|
||||
|
||||
//const emitter = new BABYLON.TransformNode('emitter', this.engine.scene);
|
||||
const emitter = BABYLON.MeshBuilder.CreateBox('emitter', { size: cm(10) }, this.engine.scene);
|
||||
emitter.position = new BABYLON.Vector3(options.position[0], options.position[1], options.position[2]);
|
||||
const ps = new BABYLON.ParticleSystem('', 32, this.engine.scene);
|
||||
ps.particleTexture = texture;
|
||||
ps.emitter = emitter;
|
||||
ps.minEmitPower = cm(100);
|
||||
ps.maxEmitPower = cm(500);
|
||||
ps.minLifeTime = 0.5;
|
||||
ps.maxLifeTime = 1;
|
||||
ps.minSize = cm(3) * textureScaleFactor;
|
||||
ps.maxSize = cm(30) * textureScaleFactor;
|
||||
ps.addDragGradient(0, 0.1);
|
||||
ps.addDragGradient(1, 0.8);
|
||||
//ps.direction1 = new BABYLON.Vector3(0, 1, 0);
|
||||
//ps.direction2 = new BABYLON.Vector3(0, 1, 0);
|
||||
ps.emitRate = 30;
|
||||
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.start();
|
||||
|
||||
this.engine.sr.fixParticleSystem(ps);
|
||||
|
||||
const launchAnim = new BABYLON.Animation(
|
||||
'',
|
||||
'position',
|
||||
60,
|
||||
BABYLON.Animation.ANIMATIONTYPE_VECTOR3,
|
||||
BABYLON.Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||
);
|
||||
launchAnim.setKeys([
|
||||
{ frame: 0, value: new BABYLON.Vector3(options.position[0], options.position[1], options.position[2]) },
|
||||
{ frame: 60, value: new BABYLON.Vector3(options.position[0], options.position[1] + cm(randomRange(2000, 6000)), options.position[2]) },
|
||||
]);
|
||||
|
||||
emitter.animations.push(launchAnim);
|
||||
const animating = Promise.withResolvers<void>();
|
||||
this.engine.scene.beginAnimation(emitter, 0, 60, false, 1, () => { animating.resolve(); });
|
||||
animating.promise.then(() => {
|
||||
ps.stop();
|
||||
|
||||
this.explode({
|
||||
position: [emitter.position.x, emitter.position.y, emitter.position.z],
|
||||
texture,
|
||||
textureScaleFactor,
|
||||
color: texturePattern.color,
|
||||
callback: () => { // explode途中でSRの状態を切り替えるとパーティクルが消える現象があるため、explodeが終了してから片づける
|
||||
this.engine.sr.disableSnapshotRendering();
|
||||
ps.dispose();
|
||||
emitter.dispose();
|
||||
this.engine.sr.enableSnapshotRendering();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
this.engine.sr.enableSnapshotRendering();
|
||||
}
|
||||
|
||||
public explode(options: {
|
||||
position: [number, number, number];
|
||||
texture: BABYLON.Texture;
|
||||
textureScaleFactor: number;
|
||||
color: BABYLON.Color3;
|
||||
callback: () => void;
|
||||
}) {
|
||||
this.engine.sr.disableSnapshotRendering();
|
||||
|
||||
const pos = new BABYLON.Vector3(options.position[0], options.position[1], options.position[2]);
|
||||
|
||||
const light = new BABYLON.PointLight('', pos, this.engine.scene, true);
|
||||
light.range = cm(10000);
|
||||
light.radius = cm(50);
|
||||
light.intensity = 100000 * WORLD_SCALE * WORLD_SCALE;
|
||||
light.diffuse = options.color;
|
||||
this.engine.lightContainer.addLight(light);
|
||||
|
||||
const lightAnim = new BABYLON.Animation(
|
||||
'',
|
||||
'intensity',
|
||||
120,
|
||||
BABYLON.Animation.ANIMATIONTYPE_FLOAT,
|
||||
BABYLON.Animation.ANIMATIONLOOPMODE_CONSTANT,
|
||||
);
|
||||
lightAnim.setKeys([
|
||||
{ frame: 0, value: 100000 * WORLD_SCALE * WORLD_SCALE },
|
||||
{ frame: 120, value: 0 },
|
||||
]);
|
||||
light.animations.push(lightAnim);
|
||||
this.engine.scene.beginAnimation(light, 0, 120, false, 1);
|
||||
|
||||
const ps = new BABYLON.ParticleSystem('', 128, this.engine.scene);
|
||||
ps.particleTexture = options.texture;
|
||||
ps.emitter = pos;
|
||||
ps.minEmitPower = cm(3700);
|
||||
ps.maxEmitPower = cm(4000);
|
||||
ps.minLifeTime = 0.8;
|
||||
ps.maxLifeTime = 1;
|
||||
ps.addDragGradient(0, 0.1);
|
||||
ps.addDragGradient(1, 0.8);
|
||||
ps.gravity = new BABYLON.Vector3(0, -50, 0).scale(WORLD_SCALE);
|
||||
ps.minSize = cm(50) * options.textureScaleFactor;
|
||||
ps.maxSize = cm(50) * options.textureScaleFactor;
|
||||
const sphereEmitter = ps.createSphereEmitter(cm(1));
|
||||
//ps.direction1 = new BABYLON.Vector3(0, 1, 0);
|
||||
//ps.direction2 = new BABYLON.Vector3(0, 1, 0);
|
||||
ps.manualEmitCount = 100;
|
||||
ps.blendMode = BABYLON.ParticleSystem.BLENDMODE_MULTIPLYADD;
|
||||
ps.addColorGradient(0.0, new BABYLON.Color4(1, 1, 1, 1));
|
||||
ps.addColorGradient(0.7, new BABYLON.Color4(1, 1, 1, 1));
|
||||
ps.addColorGradient(1.0, new BABYLON.Color4(1, 1, 1, 0));
|
||||
ps.start();
|
||||
|
||||
this.engine.sr.fixParticleSystem(ps);
|
||||
|
||||
this.timer.setTimeout(() => {
|
||||
this.engine.sr.disableSnapshotRendering();
|
||||
ps.dispose();
|
||||
light.dispose();
|
||||
this.engine.lightContainer.removeLight(light);
|
||||
this.engine.scene.removeLight(light); // lc使用時はsceneには追加してないはずだが、これがないとクラッシュする babylonのバグ?
|
||||
this.engine.sr.enableSnapshotRendering();
|
||||
|
||||
options.callback();
|
||||
}, 2000);
|
||||
|
||||
this.engine.sr.enableSnapshotRendering();
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this.timer.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as BABYLON from '@babylonjs/core/pure.js';
|
||||
import { EngineBase } from './EngineBase.js';
|
||||
import { PlayerContainer, type PlayerProfile, type PlayerState } from './PlayerContainer.js';
|
||||
|
||||
const IN_WEB_WORKER = typeof window === 'undefined';
|
||||
|
||||
export type MultiplayEngineBaseEvents = {
|
||||
'loadingProgress': (ctx: { progress: number }) => void;
|
||||
'contextlost': (ctx: { reason: string; message: string; }) => void;
|
||||
};
|
||||
|
||||
export abstract class MultiplayEngineBase<EVs extends MultiplayEngineBaseEvents> extends EngineBase<EVs> {
|
||||
protected playerProfiles: Record<string, PlayerProfile> = {};
|
||||
protected playerContainers: PlayerContainer[] = [];
|
||||
protected showUsernameOnAvatar: boolean;
|
||||
protected show2dAvatarOnAvatar: boolean;
|
||||
|
||||
constructor(options: {
|
||||
babylonEngine: BABYLON.WebGPUEngine;
|
||||
fps: number | null;
|
||||
showUsernameOnAvatar: boolean;
|
||||
show2dAvatarOnAvatar: boolean;
|
||||
}) {
|
||||
super({
|
||||
babylonEngine: options.babylonEngine,
|
||||
fps: options.fps,
|
||||
});
|
||||
|
||||
this.showUsernameOnAvatar = options.showUsernameOnAvatar;
|
||||
this.show2dAvatarOnAvatar = options.show2dAvatarOnAvatar;
|
||||
}
|
||||
|
||||
public updatePlayerProfiles(profiles: Record<string, PlayerProfile>) {
|
||||
this.playerProfiles = profiles;
|
||||
|
||||
for (const playerContainer of this.playerContainers) {
|
||||
if (this.playerProfiles[playerContainer.id] == null) {
|
||||
this.sr.disableSnapshotRendering();
|
||||
playerContainer.destroy();
|
||||
this.sr.enableSnapshotRendering();
|
||||
}
|
||||
}
|
||||
this.playerContainers = this.playerContainers.filter(p => this.playerProfiles[p.id] != null);
|
||||
}
|
||||
|
||||
public updatePlayerStates(states: Record<string, PlayerState>) {
|
||||
for (const [k, v] of Object.entries(this.playerProfiles)) {
|
||||
const playerContainer = this.playerContainers.find(p => p.id === k);
|
||||
if (playerContainer == null) {
|
||||
const p = new PlayerContainer({
|
||||
id: k,
|
||||
profile: v,
|
||||
state: states[k],
|
||||
scene: this.scene,
|
||||
sr: this.sr,
|
||||
showUsername: this.showUsernameOnAvatar,
|
||||
show2dAvatar: this.show2dAvatarOnAvatar,
|
||||
});
|
||||
// TODO: loadFurnitureのものとある程度共通化
|
||||
p.registerMeshes = (meshes) => {
|
||||
for (const mesh of meshes) {
|
||||
mesh.receiveShadows = false;
|
||||
mesh.metadata = { isPlayer: true, playerId: k };
|
||||
|
||||
//if (mesh.material) (mesh.material as BABYLON.PBRMaterial).ambientColor = new BABYLON.Color3(0.2, 0.2, 0.2);
|
||||
if (mesh.material) {
|
||||
if (mesh.material instanceof BABYLON.MultiMaterial) {
|
||||
for (const subMat of mesh.material.subMaterials) {
|
||||
if ((subMat as BABYLON.PBRMaterial).subSurface.isRefractionEnabled) {
|
||||
(subMat as BABYLON.PBRMaterial).subSurface.isRefractionEnabled = false; // 有効にするとドローコールが激増する
|
||||
(subMat as BABYLON.PBRMaterial).transparencyMode = BABYLON.PBRMaterial.PBRMATERIAL_ALPHABLEND;
|
||||
(subMat as BABYLON.PBRMaterial).alpha = 0.5;
|
||||
(subMat as BABYLON.PBRMaterial).metallic = 1;
|
||||
}
|
||||
(subMat as BABYLON.PBRMaterial).reflectionTexture = this.getEnvMap();
|
||||
if ((subMat as BABYLON.PBRMaterial).metadata == null) (subMat as BABYLON.PBRMaterial).metadata = {};
|
||||
(subMat as BABYLON.PBRMaterial).metadata.useEnvMap = true;
|
||||
(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 {
|
||||
if ((mesh.material as BABYLON.PBRMaterial).subSurface.isRefractionEnabled) {
|
||||
(mesh.material as BABYLON.PBRMaterial).subSurface.isRefractionEnabled = false; // 有効にするとドローコールが激増する
|
||||
(mesh.material as BABYLON.PBRMaterial).transparencyMode = BABYLON.PBRMaterial.PBRMATERIAL_ALPHABLEND;
|
||||
(mesh.material as BABYLON.PBRMaterial).alpha = 0.5;
|
||||
(mesh.material as BABYLON.PBRMaterial).metallic = 1;
|
||||
}
|
||||
(mesh.material as BABYLON.PBRMaterial).reflectionTexture = this.getEnvMap();
|
||||
if ((mesh.material as BABYLON.PBRMaterial).metadata == null) (mesh.material as BABYLON.PBRMaterial).metadata = {};
|
||||
(mesh.material as BABYLON.PBRMaterial).metadata.useEnvMap = true;
|
||||
(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);
|
||||
}
|
||||
};
|
||||
p.loadAvatar().then(() => {
|
||||
this.sr.disableSnapshotRendering();
|
||||
this.sr.enableSnapshotRendering();
|
||||
});
|
||||
this.playerContainers.push(p);
|
||||
} else {
|
||||
if (states[k] != null) {
|
||||
playerContainer.applyState(states[k]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public clearPlayers() {
|
||||
this.sr.disableSnapshotRendering();
|
||||
for (const playerContainer of this.playerContainers) {
|
||||
playerContainer.destroy();
|
||||
}
|
||||
this.sr.enableSnapshotRendering();
|
||||
this.playerContainers = [];
|
||||
}
|
||||
|
||||
public updateAvatarDisplayOptions(options: { showUsername: boolean; show2dAvatar: boolean }) {
|
||||
this.showUsernameOnAvatar = options.showUsername;
|
||||
this.show2dAvatarOnAvatar = options.show2dAvatar;
|
||||
|
||||
this.sr.disableSnapshotRendering();
|
||||
for (const playerContainer of this.playerContainers) {
|
||||
playerContainer.updateUserInfoDisplayOptions(options);
|
||||
}
|
||||
this.sr.enableSnapshotRendering();
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
for (const playerContainer of this.playerContainers) {
|
||||
playerContainer.destroy();
|
||||
}
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as BABYLON from '@babylonjs/core/pure.js';
|
||||
import { cm, WORLD_SCALE } from 'misskey-world/src/utility.js';
|
||||
import { AccessoryContainer } from './avatars/AccessoryContainer.js';
|
||||
import { getAccessoryDef } from './avatars/accessory-defs.js';
|
||||
import { createTextMesh, Timer } from './utility.js';
|
||||
import type { WorldAvatar } from 'misskey-world/src/types.js';
|
||||
|
||||
export type PlayerProfile = {
|
||||
user: {
|
||||
name: string;
|
||||
username: string;
|
||||
avatarUrl: string;
|
||||
} | null;
|
||||
avatar: WorldAvatar;
|
||||
};
|
||||
|
||||
export type PlayerState = {
|
||||
position: [number, number, number],
|
||||
rotation: [number, number, number],
|
||||
sit?: string; // id
|
||||
};
|
||||
|
||||
const DEFAULT_FACE_PARTS_EYES = {
|
||||
'_none_': null,
|
||||
'a': '/client-assets/world/avatars/eyes-a.png',
|
||||
'b': '/client-assets/world/avatars/eyes-b.png',
|
||||
'c': '/client-assets/world/avatars/eyes-c.png',
|
||||
'd': '/client-assets/world/avatars/eyes-d.png',
|
||||
'e': '/client-assets/world/avatars/eyes-e.png',
|
||||
'f': '/client-assets/world/avatars/eyes-f.png',
|
||||
'g': '/client-assets/world/avatars/eyes-g.png',
|
||||
};
|
||||
|
||||
const DEFAULT_FACE_PARTS_MOUTH = {
|
||||
'_none_': null,
|
||||
'a': '/client-assets/world/avatars/mouth-a.png',
|
||||
'b': '/client-assets/world/avatars/mouth-b.png',
|
||||
'c': '/client-assets/world/avatars/mouth-c.png',
|
||||
'd': '/client-assets/world/avatars/mouth-d.png',
|
||||
'e': '/client-assets/world/avatars/mouth-e.png',
|
||||
'f': '/client-assets/world/avatars/mouth-f.png',
|
||||
'g': '/client-assets/world/avatars/mouth-g.png',
|
||||
'h': '/client-assets/world/avatars/mouth-h.png',
|
||||
'i': '/client-assets/world/avatars/mouth-i.png',
|
||||
};
|
||||
|
||||
let usernameLabelMaterial: BABYLON.StandardMaterial | null = null;
|
||||
|
||||
export class PlayerContainer {
|
||||
public id: string;
|
||||
private profile: PlayerProfile;
|
||||
public root: BABYLON.TransformNode;
|
||||
private subRootContainerForAnim: BABYLON.TransformNode;
|
||||
private subRoot: BABYLON.TransformNode;
|
||||
private modelRoot: BABYLON.TransformNode | null = null;
|
||||
private sr: BABYLON.SnapshotRenderingHelper;
|
||||
private scene: BABYLON.Scene;
|
||||
public registerMeshes: (meshes: BABYLON.Mesh[]) => void = () => {};
|
||||
private animationObserver: BABYLON.Observer<BABYLON.Scene> | null = null;
|
||||
private accessoryContainers: AccessoryContainer[] = [];
|
||||
private timer: Timer = new Timer();
|
||||
private showUsername: boolean;
|
||||
private show2dAvatar: boolean;
|
||||
private usernameLabelMesh: BABYLON.Mesh | null = null;
|
||||
private twodAvatarMesh: BABYLON.Mesh | null = null;
|
||||
|
||||
constructor(params: { id: string; profile: PlayerProfile; state: PlayerState | null; sr: BABYLON.SnapshotRenderingHelper; scene: BABYLON.Scene; showUsername: boolean; show2dAvatar: boolean; }) {
|
||||
this.id = params.id;
|
||||
this.profile = params.profile;
|
||||
this.sr = params.sr;
|
||||
this.scene = params.scene;
|
||||
|
||||
this.root = new BABYLON.TransformNode(`player:${this.id}`, params.scene);
|
||||
this.root.rotationQuaternion = null;
|
||||
this.subRootContainerForAnim = new BABYLON.TransformNode(`player:${this.id}:subRootContainerForAnim`, params.scene);
|
||||
this.subRootContainerForAnim.parent = this.root;
|
||||
this.subRoot = new BABYLON.TransformNode(`player:${this.id}:subRoot`, params.scene);
|
||||
this.subRoot.parent = this.subRootContainerForAnim;
|
||||
|
||||
this.showUsername = params.showUsername;
|
||||
this.show2dAvatar = params.show2dAvatar;
|
||||
this.applyInfoMesh();
|
||||
|
||||
if (params.state) this.applyState(params.state, true);
|
||||
}
|
||||
|
||||
public async loadAvatar() {
|
||||
const filePath = '/client-assets/world/avatars/default.glb';
|
||||
const loaderResult = await BABYLON.LoadAssetContainerAsync(filePath, this.scene);
|
||||
|
||||
// babylonによって自動で追加される右手系変換用ノード
|
||||
const modelRootMesh = loaderResult.meshes[0] as BABYLON.Mesh;
|
||||
|
||||
// meshじゃなくtransform nodeにしてパフォーマンス向上
|
||||
this.modelRoot = new BABYLON.TransformNode('__root__', this.scene);
|
||||
this.modelRoot.parent = this.subRoot;
|
||||
this.modelRoot.scaling.x = -1;
|
||||
this.modelRoot.scaling = this.modelRoot.scaling.scale(WORLD_SCALE);// cmをmに
|
||||
|
||||
for (const m of modelRootMesh.getChildren()) {
|
||||
if (m.parent === modelRootMesh) {
|
||||
m.parent = this.modelRoot;
|
||||
}
|
||||
}
|
||||
|
||||
modelRootMesh.dispose();
|
||||
|
||||
const eyesBlinkTexture = new BABYLON.Texture('/client-assets/world/avatars/eyes-blink.png', this.scene, false, false);
|
||||
eyesBlinkTexture.hasAlpha = true;
|
||||
|
||||
let eyesTex: BABYLON.Texture | null = null;
|
||||
if (this.profile.avatar.eyes.type in DEFAULT_FACE_PARTS_EYES) {
|
||||
const eyesTexPath = DEFAULT_FACE_PARTS_EYES[this.profile.avatar.eyes.type];
|
||||
if (eyesTexPath) {
|
||||
eyesTex = new BABYLON.Texture(eyesTexPath, this.scene, false, false);
|
||||
eyesTex.hasAlpha = true;
|
||||
}
|
||||
}
|
||||
|
||||
let mouthTex: BABYLON.Texture | null = null;
|
||||
if (this.profile.avatar.mouth.type in DEFAULT_FACE_PARTS_MOUTH) {
|
||||
const mouthTexPath = DEFAULT_FACE_PARTS_MOUTH[this.profile.avatar.mouth.type];
|
||||
if (mouthTexPath) {
|
||||
mouthTex = new BABYLON.Texture(mouthTexPath, this.scene, false, false);
|
||||
mouthTex.hasAlpha = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const mesh of this.modelRoot.getChildMeshes()) {
|
||||
if (mesh.name.includes('__BODY__')) {
|
||||
mesh.material.albedoColor = new BABYLON.Color3(this.profile.avatar.body.color[0], this.profile.avatar.body.color[1], this.profile.avatar.body.color[2]);
|
||||
}
|
||||
if (mesh.name.includes('__EYES__')) {
|
||||
const mat = new BABYLON.PBRMaterial('', this.scene);
|
||||
mat.albedoColor = new BABYLON.Color3(this.profile.avatar.eyes.color[0], this.profile.avatar.eyes.color[1], this.profile.avatar.eyes.color[2]);
|
||||
mat.albedoTexture = eyesTex;
|
||||
mat.roughness = 1;
|
||||
mat.metallic = 0;
|
||||
mesh.material = mat;
|
||||
|
||||
// TODO: SRを無効にせずに表現する方法を考える
|
||||
const blink = () => {
|
||||
if (mesh.isDisposed()) return;
|
||||
|
||||
this.sr.disableSnapshotRendering();
|
||||
mat.albedoTexture = eyesBlinkTexture;
|
||||
this.sr.enableSnapshotRendering();
|
||||
|
||||
this.timer.setTimeout(() => {
|
||||
this.sr.disableSnapshotRendering();
|
||||
mat.albedoTexture = eyesTex;
|
||||
this.sr.enableSnapshotRendering();
|
||||
|
||||
this.timer.setTimeout(() => {
|
||||
blink();
|
||||
}, Math.random() * 10000);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
this.timer.setTimeout(() => {
|
||||
blink();
|
||||
}, Math.random() * 10000);
|
||||
}
|
||||
if (mesh.name.includes('__MOUTH__')) {
|
||||
if (mouthTex != null) {
|
||||
const mat = new BABYLON.PBRMaterial('', this.scene);
|
||||
mat.albedoColor = new BABYLON.Color3(this.profile.avatar.mouth.color[0], this.profile.avatar.mouth.color[1], this.profile.avatar.mouth.color[2]);
|
||||
mat.albedoTexture = mouthTex;
|
||||
mat.roughness = 1;
|
||||
mat.metallic = 0;
|
||||
mesh.material = mat;
|
||||
} else {
|
||||
mesh.isVisible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.registerMeshes(this.modelRoot.getChildMeshes());
|
||||
|
||||
this.accessoryContainers = await Promise.all(this.profile.avatar.accessories.map(ac => this.loadAccessory({
|
||||
type: ac.type,
|
||||
id: ac.id,
|
||||
position: new BABYLON.Vector3(0, cm(19), 0),
|
||||
rotation: new BABYLON.Vector3(0, 0, 0),
|
||||
options: ac.options,
|
||||
})));
|
||||
|
||||
const anim = new BABYLON.Animation('', 'position.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
|
||||
anim.setKeys([
|
||||
{ frame: 0, value: cm(0) },
|
||||
{ frame: 30, value: cm(-2) },
|
||||
{ frame: 60, value: cm(0) },
|
||||
{ frame: 90, value: cm(2) },
|
||||
{ frame: 120, value: cm(0) },
|
||||
]);
|
||||
this.subRootContainerForAnim.animations = [anim];
|
||||
this.animationObserver = this.scene.onAfterAnimationsObservable.add(() => {
|
||||
this.sr.updateMesh(this.subRootContainerForAnim.getChildMeshes(), false);
|
||||
});
|
||||
this.scene.beginAnimation(this.subRootContainerForAnim, 0, 120, true);
|
||||
}
|
||||
|
||||
private async loadAccessory(args: {
|
||||
type: string;
|
||||
id: string;
|
||||
position: BABYLON.Vector3;
|
||||
rotation: BABYLON.Vector3;
|
||||
options: Record<string, unknown>;
|
||||
}) {
|
||||
const def = getAccessoryDef(args.type);
|
||||
|
||||
const container = new AccessoryContainer({
|
||||
id: args.id,
|
||||
type: args.type,
|
||||
position: args.position.clone(),
|
||||
rotation: args.rotation.clone(),
|
||||
options: args.options,
|
||||
sr: this.sr,
|
||||
getIsSrReady: () => true,
|
||||
lightContainer: this.lightContainer,
|
||||
graphicsQuality: this.graphicsQuality,
|
||||
scene: this.scene,
|
||||
});
|
||||
container.registerMeshes = (meshes) => {
|
||||
this.registerMeshes(meshes);
|
||||
};
|
||||
|
||||
await container.load();
|
||||
|
||||
container.root.parent = this.subRoot;
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
private applyInfoMesh() {
|
||||
if (this.showUsername ) {
|
||||
if (this.usernameLabelMesh == null) {
|
||||
if (usernameLabelMaterial == null) {
|
||||
const usernameLabelTex = new BABYLON.Texture('/client-assets/world/chars-black.png', this.scene, false, false);
|
||||
usernameLabelMaterial = new BABYLON.StandardMaterial('usernameLabelMaterial', this.scene);
|
||||
usernameLabelMaterial.roughness = 1;
|
||||
usernameLabelMaterial.diffuseColor = new BABYLON.Color3(1, 1, 1);
|
||||
usernameLabelMaterial.diffuseTexture = usernameLabelTex;
|
||||
usernameLabelMaterial.emissiveColor = new BABYLON.Color3(1, 1, 1);
|
||||
usernameLabelMaterial.emissiveTexture = usernameLabelTex;
|
||||
usernameLabelMaterial.disableLighting = true;
|
||||
}
|
||||
|
||||
this.usernameLabelMesh = createTextMesh(this.profile.user?.username ?? '(anonymous)', {
|
||||
size: cm(5),
|
||||
material: usernameLabelMaterial,
|
||||
});
|
||||
this.usernameLabelMesh.parent = this.subRoot;
|
||||
this.usernameLabelMesh.position.y = cm(40);
|
||||
this.usernameLabelMesh.billboardMode = BABYLON.Mesh.BILLBOARDMODE_ALL;
|
||||
this.scene.addMesh(this.usernameLabelMesh);
|
||||
}
|
||||
} else {
|
||||
if (this.usernameLabelMesh != null) {
|
||||
this.usernameLabelMesh.dispose();
|
||||
this.scene.removeMesh(this.usernameLabelMesh);
|
||||
this.usernameLabelMesh = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.show2dAvatar && this.profile.user?.avatarUrl != null) {
|
||||
if (this.twodAvatarMesh == null) {
|
||||
const twodAvatarTex = new BABYLON.Texture(this.profile.user.avatarUrl, this.scene, false, true);
|
||||
const twodAvatarMat = new BABYLON.StandardMaterial('twodAvatarMat', this.scene);
|
||||
twodAvatarMat.roughness = 1;
|
||||
twodAvatarMat.diffuseColor = new BABYLON.Color3(0.5, 0.5, 0.5);
|
||||
twodAvatarMat.diffuseTexture = twodAvatarTex;
|
||||
twodAvatarMat.emissiveColor = new BABYLON.Color3(0.5, 0.5, 0.5);
|
||||
twodAvatarMat.emissiveTexture = twodAvatarTex;
|
||||
twodAvatarMat.disableLighting = true;
|
||||
twodAvatarMat.backFaceCulling = false;
|
||||
|
||||
this.twodAvatarMesh = BABYLON.MeshBuilder.CreatePlane('twodAvatar', { size: cm(10) }, this.scene);
|
||||
this.twodAvatarMesh.material = twodAvatarMat;
|
||||
this.twodAvatarMesh.parent = this.subRoot;
|
||||
this.twodAvatarMesh.position.y = cm(40) + cm(7.5);
|
||||
this.twodAvatarMesh.billboardMode = BABYLON.Mesh.BILLBOARDMODE_ALL;
|
||||
this.scene.addMesh(this.twodAvatarMesh);
|
||||
}
|
||||
} else {
|
||||
if (this.twodAvatarMesh != null) {
|
||||
this.twodAvatarMesh.dispose(false, true);
|
||||
this.scene.removeMesh(this.twodAvatarMesh);
|
||||
this.twodAvatarMesh = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public updateUserInfoDisplayOptions(options: { showUsername: boolean; show2dAvatar: boolean; }) {
|
||||
this.showUsername = options.showUsername;
|
||||
this.show2dAvatar = options.show2dAvatar;
|
||||
this.applyInfoMesh();
|
||||
}
|
||||
|
||||
public applyState(state: PlayerState, forInit = false) {
|
||||
this.root.position.set(...state.position);
|
||||
this.subRoot.rotation.set(...state.rotation);
|
||||
if (!forInit) {
|
||||
const meshes = this.root.getChildMeshes();
|
||||
if (meshes.length > 0) this.sr.updateMesh(meshes);
|
||||
}
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.timer.dispose();
|
||||
if (this.animationObserver != null) {
|
||||
this.scene.onAfterAnimationsObservable.remove(this.animationObserver);
|
||||
}
|
||||
for (const ac of this.accessoryContainers) {
|
||||
ac.destroy();
|
||||
}
|
||||
this.accessoryContainers = [];
|
||||
if (this.usernameLabelMesh != null) {
|
||||
this.usernameLabelMesh.dispose();
|
||||
this.scene.removeMesh(this.usernameLabelMesh);
|
||||
this.usernameLabelMesh = null;
|
||||
}
|
||||
if (this.twodAvatarMesh != null) {
|
||||
this.twodAvatarMesh.dispose(false, true);
|
||||
this.scene.removeMesh(this.twodAvatarMesh);
|
||||
this.twodAvatarMesh = null;
|
||||
}
|
||||
this.root.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as BABYLON from '@babylonjs/core/pure.js';
|
||||
import { registerBuiltInLoaders } from '@babylonjs/loaders/dynamic.js';
|
||||
import { cm, WORLD_SCALE } from 'misskey-world/src/utility.js';
|
||||
import { ArcRotateCameraManualInput, getMeshesBoundingBox, GRAPHICS_QUALITY } from './utility.js';
|
||||
import { PlayerContainer, type PlayerProfile } from './PlayerContainer.js';
|
||||
import { EngineBase } from './EngineBase.js';
|
||||
import { deepClone } from './clone.js';
|
||||
import type { WorldAvatar } from 'misskey-world/src/types.js';
|
||||
|
||||
export class AvatarPreviewEngine extends EngineBase<{ // PlayerPreviewEngineに改名した方がいいかもしれない
|
||||
'loadingProgress': (ctx: { progress: number }) => void;
|
||||
'contextlost': (ctx: { reason: string; message: string; }) => void;
|
||||
}> {
|
||||
private sr: BABYLON.SnapshotRenderingHelper;
|
||||
private shadowGenerator: BABYLON.ShadowGenerator;
|
||||
private camera: BABYLON.ArcRotateCamera;
|
||||
private avatarOptions: WorldAvatar | null = null;
|
||||
private playerContainer: PlayerContainer | null = null;
|
||||
private envMapIndoor: BABYLON.CubeTexture;
|
||||
private roomLight: BABYLON.SpotLight;
|
||||
private pipeline: BABYLON.DefaultRenderingPipeline;
|
||||
private graphicsQuality: number;
|
||||
private profile: PlayerProfile;
|
||||
|
||||
constructor(profile: PlayerProfile, options: {
|
||||
babylonEngine: BABYLON.WebGPUEngine;
|
||||
graphicsQuality: number;
|
||||
fps: number | null;
|
||||
}) {
|
||||
super({
|
||||
babylonEngine: options.babylonEngine,
|
||||
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.babylonEngine.resize(true);
|
||||
// workerで実行される可能性がある
|
||||
|
||||
setTimeout(() => {
|
||||
this.sr.enableSnapshotRendering();
|
||||
}, 1);
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
super.destroy();
|
||||
this.playerContainer?.destroy();
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as BABYLON from '@babylonjs/core/pure.js';
|
||||
import { AvatarPreviewEngine } from './avatarPreviewEngine.js';
|
||||
import { registerBabylonRuntime } from './babylonRuntime.js';
|
||||
import type { PlayerProfile } from './PlayerContainer.js';
|
||||
|
||||
registerBabylonRuntime();
|
||||
|
||||
export async function createAvatarPreviewEngine(params: {
|
||||
canvas: HTMLCanvasElement; options: { graphicsQuality: number; resolution: number; fps: number | null }; profile: PlayerProfile;
|
||||
}) {
|
||||
const babylonEngine = new BABYLON.WebGPUEngine(params.canvas, { doNotHandleContextLost: true, powerPreference: 'low-power', antialias: true });
|
||||
babylonEngine.compatibilityMode = false;
|
||||
babylonEngine.enableOfflineSupport = false;
|
||||
await babylonEngine.initAsync();
|
||||
if (params.options.resolution === 2) babylonEngine.setHardwareScalingLevel(0.5);
|
||||
if (params.options.resolution === 0.5) babylonEngine.setHardwareScalingLevel(2);
|
||||
|
||||
const engine = new AvatarPreviewEngine(params.profile, {
|
||||
babylonEngine: babylonEngine,
|
||||
...params.options,
|
||||
});
|
||||
|
||||
return engine;
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as BABYLON from '@babylonjs/core/pure.js';
|
||||
import { AvatarPreviewEngine } from './avatarPreviewEngine.js';
|
||||
import { registerBabylonRuntime } from './babylonRuntime.js';
|
||||
import type { PlayerProfile } from './PlayerContainer.js';
|
||||
|
||||
registerBabylonRuntime();
|
||||
|
||||
let engine: AvatarPreviewEngine | null = null;
|
||||
let canvas: OffscreenCanvas | null = null;
|
||||
|
||||
// TODO: 他のWorkerと実装を共通化
|
||||
onmessage = async (event) => {
|
||||
//console.log('Worker received message:', event.data);
|
||||
|
||||
switch (event.data?.type) {
|
||||
case 'init': {
|
||||
const profile = event.data.profile as PlayerProfile;
|
||||
canvas = event.data.canvas as OffscreenCanvas;
|
||||
const babylonEngine = new BABYLON.WebGPUEngine(canvas, { doNotHandleContextLost: true, powerPreference: 'low-power', antialias: true });
|
||||
babylonEngine.compatibilityMode = false;
|
||||
babylonEngine.enableOfflineSupport = false;
|
||||
await babylonEngine.initAsync();
|
||||
if (event.data.options.resolution === 2) babylonEngine.setHardwareScalingLevel(0.5);
|
||||
if (event.data.options.resolution === 0.5) babylonEngine.setHardwareScalingLevel(2);
|
||||
|
||||
engine = new AvatarPreviewEngine(profile, {
|
||||
babylonEngine: babylonEngine,
|
||||
...event.data.options,
|
||||
});
|
||||
|
||||
engine.on('ev', ({ type, ctx }) => {
|
||||
self.postMessage({ type: 'ev', ev: { type, ctx } });
|
||||
});
|
||||
|
||||
await engine.init();
|
||||
|
||||
self.postMessage({ type: 'inited' });
|
||||
break;
|
||||
}
|
||||
case 'resize': {
|
||||
canvas.width = event.data.width;
|
||||
canvas.height = event.data.height;
|
||||
if (engine != null) engine.resize();
|
||||
break;
|
||||
}
|
||||
case 'input:keydown': {
|
||||
if (engine == null) break;
|
||||
engine.inputs.emit('keydown', event.data.ev);
|
||||
break;
|
||||
}
|
||||
case 'input:keyup': {
|
||||
if (engine == null) break;
|
||||
engine.inputs.emit('keyup', event.data.ev);
|
||||
break;
|
||||
}
|
||||
case 'input:click': {
|
||||
if (engine == null) break;
|
||||
engine.inputs.emit('click', event.data.ev);
|
||||
break;
|
||||
}
|
||||
case 'input:wheel': {
|
||||
if (engine == null) break;
|
||||
engine.inputs.emit('wheel', event.data.ev);
|
||||
break;
|
||||
}
|
||||
case 'input:zoom': {
|
||||
if (engine == null) break;
|
||||
engine.inputs.emit('zoom', event.data.ev);
|
||||
break;
|
||||
}
|
||||
case 'input:pointer': {
|
||||
if (engine == null) break;
|
||||
engine.inputs.emit('pointer', event.data.ev);
|
||||
break;
|
||||
}
|
||||
case 'call': {
|
||||
if (engine == null) {
|
||||
console.error('Failed to call: Engine is not initialized yet!!!');
|
||||
break;
|
||||
}
|
||||
const res = engine[event.data.fn](...(event.data.args ?? []));
|
||||
if (event.data.needReturnValue) {
|
||||
if (res instanceof Promise) {
|
||||
res.then((r) => {
|
||||
self.postMessage({ type: 'return', id: event.data.id, value: r });
|
||||
});
|
||||
} else {
|
||||
self.postMessage({ type: 'return', id: event.data.id, value: res });
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'set': {
|
||||
if (engine == null) {
|
||||
console.error('Failed to set: Engine is not initialized yet!!!');
|
||||
break;
|
||||
}
|
||||
engine[event.data.key] = event.data.value;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.warn('Unrecognized message type:', event.data?.type);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,142 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as BABYLON from '@babylonjs/core/pure.js';
|
||||
import { camelToKebab, WORLD_SCALE } from 'misskey-world/src/utility.js';
|
||||
import { ModelExplorer, scaleMorph, Timer } from '../utility.js';
|
||||
import { convertRawOptions, type ConvertedOptions, type RawOptions } from '../mono.js';
|
||||
import { getAccessoryDef } from './accessory-defs.js';
|
||||
import type { AvatarAccessoryInstance } from './accessory.js';
|
||||
|
||||
export class AccessoryContainer {
|
||||
public id: string;
|
||||
public type: string;
|
||||
private options: ConvertedOptions;
|
||||
public root: BABYLON.TransformNode;
|
||||
private subRoot: BABYLON.TransformNode | null = null;
|
||||
public instance: AvatarAccessoryInstance | null = null;
|
||||
public model: ModelExplorer | null = null;
|
||||
private scene: BABYLON.Scene;
|
||||
public registerMeshes: (meshes: BABYLON.Mesh[]) => void = () => {};
|
||||
private sr: BABYLON.SnapshotRenderingHelper;
|
||||
private getIsSrReady: () => boolean;
|
||||
private lightContainer: BABYLON.ClusteredLightContainer;
|
||||
private graphicsQuality: number;
|
||||
private timer: Timer = new Timer();
|
||||
|
||||
constructor(args: {
|
||||
id: string;
|
||||
type: string;
|
||||
options: RawOptions;
|
||||
position: BABYLON.Vector3;
|
||||
rotation: BABYLON.Vector3;
|
||||
sr: BABYLON.SnapshotRenderingHelper;
|
||||
getIsSrReady: () => boolean;
|
||||
lightContainer: BABYLON.ClusteredLightContainer;
|
||||
scene: BABYLON.Scene;
|
||||
graphicsQuality: number;
|
||||
}) {
|
||||
this.id = args.id;
|
||||
this.type = args.type;
|
||||
const def = getAccessoryDef(this.type);
|
||||
this.options = convertRawOptions(def.options.schema, args.options, { files: [] });
|
||||
this.sr = args.sr;
|
||||
this.getIsSrReady = args.getIsSrReady;
|
||||
this.lightContainer = args.lightContainer;
|
||||
this.scene = args.scene;
|
||||
this.graphicsQuality = args.graphicsQuality;
|
||||
this.root = new BABYLON.TransformNode(`accessory_${args.id}_${args.type}`, this.scene);
|
||||
this.root.position = args.position;
|
||||
this.root.rotation = args.rotation;
|
||||
}
|
||||
|
||||
public async load() {
|
||||
const def = getAccessoryDef(this.type);
|
||||
|
||||
const filePath = def.path != null ? `/client-assets/world/objects/${def.path(this.options)}.glb` : `/client-assets/world/objects/${camelToKebab(this.type)}/${camelToKebab(this.type)}.glb`;
|
||||
const loaderResult = await BABYLON.LoadAssetContainerAsync(filePath, this.scene);
|
||||
|
||||
// babylonによって自動で追加される右手系変換用ノード
|
||||
const subRootMesh = loaderResult.meshes[0] as BABYLON.Mesh;
|
||||
|
||||
// meshじゃなくtransform nodeにしてパフォーマンス向上
|
||||
this.subRoot = new BABYLON.TransformNode('__root__', this.scene);
|
||||
this.subRoot.parent = this.root;
|
||||
this.subRoot.scaling.x = -1;
|
||||
this.subRoot.scaling = this.subRoot.scaling.scale(WORLD_SCALE);// cmをmに
|
||||
|
||||
for (const m of subRootMesh.getChildren()) {
|
||||
if (m.parent === subRootMesh) {
|
||||
m.parent = this.subRoot;
|
||||
}
|
||||
}
|
||||
|
||||
subRootMesh.dispose();
|
||||
|
||||
this.registerMeshes(this.subRoot.getChildMeshes());
|
||||
|
||||
this.model = new ModelExplorer(this.subRoot);
|
||||
|
||||
this.instance = await def.createInstance({
|
||||
scene: this.scene,
|
||||
sr: {
|
||||
updateMesh: (mesh) => {
|
||||
if (!this.getIsSrReady()) return;
|
||||
this.sr.updateMesh(mesh);
|
||||
},
|
||||
reset: () => {
|
||||
if (!this.getIsSrReady()) return;
|
||||
this.sr.disableSnapshotRendering();
|
||||
this.sr.enableSnapshotRendering();
|
||||
},
|
||||
fixParticleSystem: (ps) => this.sr.fixParticleSystem(ps),
|
||||
},
|
||||
lc: this.lightContainer,
|
||||
root: this.root,
|
||||
options: this.options,
|
||||
model: this.model!,
|
||||
timer: this.timer,
|
||||
graphicsQuality: this.graphicsQuality,
|
||||
reloadModel: () => {
|
||||
this.reload();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public async reload() {
|
||||
this.timer.dispose();
|
||||
this.instance?.dispose?.();
|
||||
this.instance = null;
|
||||
this.model = null;
|
||||
this.subRoot?.dispose();
|
||||
this.root.removeChild(this.subRoot);
|
||||
this.scene.removeTransformNode(this.subRoot);
|
||||
|
||||
this.timer = new Timer();
|
||||
|
||||
await this.load();
|
||||
|
||||
this.sr.disableSnapshotRendering();
|
||||
this.sr.enableSnapshotRendering();
|
||||
}
|
||||
|
||||
public optionsUpdated(options: Record<string, unknown>, key: string, value: any) {
|
||||
if (this.instance == null) return;
|
||||
this.options[key] = options[key]; // 参照を切れさせないようにプロパティ個別にmutate
|
||||
this.sr.disableSnapshotRendering();
|
||||
this.instance.onOptionsUpdated?.([key, this.options[key]]);
|
||||
this.sr.enableSnapshotRendering();
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.sr.disableSnapshotRendering();
|
||||
this.timer.dispose();
|
||||
this.instance?.dispose?.();
|
||||
this.subRoot.dispose();
|
||||
this.root.dispose();
|
||||
this.scene.removeTransformNode(this.root);
|
||||
this.sr.enableSnapshotRendering();
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as BABYLON from '@babylonjs/core/pure.js';
|
||||
import { bolt_schema } from 'misskey-world/src/avatars/accessories/bolt.schema.js';
|
||||
import { defineAccessory } from '../accessory.js';
|
||||
|
||||
export const bolt = defineAccessory(bolt_schema, {
|
||||
createInstance: ({ model, options }) => {
|
||||
const material = model.findMaterial('__X_BOLT__');
|
||||
|
||||
const applyMat = () => {
|
||||
material.albedoColor = new BABYLON.Color3(options.mat.color[0], options.mat.color[1], options.mat.color[2]);
|
||||
material.roughness = options.mat.roughness;
|
||||
material.metallic = options.mat.metallic;
|
||||
};
|
||||
|
||||
applyMat();
|
||||
|
||||
return {
|
||||
onOptionsUpdated: ([k, v]) => {
|
||||
switch (k) {
|
||||
case 'mat': applyMat(); break;
|
||||
}
|
||||
},
|
||||
dispose: () => {
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as BABYLON from '@babylonjs/core/pure.js';
|
||||
import { mikan_schema } from 'misskey-world/src/avatars/accessories/mikan.schema.js';
|
||||
import { defineAccessory } from '../accessory.js';
|
||||
|
||||
export const mikan = defineAccessory(mikan_schema, {
|
||||
createInstance: ({ scene, root, sr }) => {
|
||||
return {
|
||||
dispose: () => {
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as BABYLON from '@babylonjs/core/pure.js';
|
||||
import { cm } from 'misskey-world/src/utility.js';
|
||||
import { mug_schema } from 'misskey-world/src/avatars/accessories/mug.schema.js';
|
||||
import { defineAccessory } from '../accessory.js';
|
||||
|
||||
export const mug = defineAccessory(mug_schema, {
|
||||
createInstance: ({ options, scene, root, sr, model }) => {
|
||||
const emitter = new BABYLON.TransformNode('emitter', scene);
|
||||
emitter.parent = root;
|
||||
emitter.position = new BABYLON.Vector3(0, cm(5), 0);
|
||||
const ps = new BABYLON.ParticleSystem('steamParticleSystem', 8, scene);
|
||||
ps.particleTexture = new BABYLON.Texture('/client-assets/world/objects/mug/steam.png');
|
||||
ps.emitter = emitter;
|
||||
ps.minEmitBox = new BABYLON.Vector3(cm(-1), 0, cm(-1));
|
||||
ps.maxEmitBox = new BABYLON.Vector3(cm(1), 0, cm(1));
|
||||
ps.minEmitPower = cm(10);
|
||||
ps.maxEmitPower = cm(12);
|
||||
ps.minLifeTime = 2;
|
||||
ps.maxLifeTime = 3;
|
||||
ps.addSizeGradient(0, cm(10), cm(12));
|
||||
ps.addSizeGradient(1, cm(18), cm(20));
|
||||
ps.direction1 = new BABYLON.Vector3(-0.3, 1, 0.3);
|
||||
ps.direction2 = new BABYLON.Vector3(0.3, 1, -0.3);
|
||||
ps.emitRate = 0.5;
|
||||
ps.blendMode = BABYLON.ParticleSystem.BLENDMODE_ADD;
|
||||
ps.color1 = new BABYLON.Color4(1, 1, 1, 0.3);
|
||||
ps.color2 = new BABYLON.Color4(1, 1, 1, 0.2);
|
||||
ps.colorDead = new BABYLON.Color4(1, 1, 1, 0);
|
||||
ps.preWarmCycles = Math.random() * 1000;
|
||||
ps.start();
|
||||
sr.fixParticleSystem(ps);
|
||||
|
||||
const bodyMaterial = model.findMaterial('__X_MUG__');
|
||||
|
||||
const applyBodyMat = () => {
|
||||
bodyMaterial.albedoColor = new BABYLON.Color3(options.bodyMat.color[0], options.bodyMat.color[1], options.bodyMat.color[2]);
|
||||
bodyMaterial.roughness = options.bodyMat.roughness;
|
||||
bodyMaterial.metallic = options.bodyMat.metallic;
|
||||
};
|
||||
|
||||
applyBodyMat();
|
||||
|
||||
const liquidMaterial = model.findMaterial('__X_LIQUID__');
|
||||
|
||||
const applyLiquidMat = () => {
|
||||
liquidMaterial.albedoColor = new BABYLON.Color3(options.liquidMat.color[0], options.liquidMat.color[1], options.liquidMat.color[2]);
|
||||
liquidMaterial.roughness = options.liquidMat.roughness;
|
||||
liquidMaterial.metallic = options.liquidMat.metallic;
|
||||
};
|
||||
|
||||
applyLiquidMat();
|
||||
|
||||
return {
|
||||
onOptionsUpdated: ([k, v]) => {
|
||||
switch (k) {
|
||||
case 'bodyMat': applyBodyMat(); break;
|
||||
case 'liquidMat': applyLiquidMat(); break;
|
||||
}
|
||||
},
|
||||
dispose: () => {
|
||||
ps.stop();
|
||||
emitter.dispose();
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { mug } from './accessories/mug.js';
|
||||
import { mikan } from './accessories/mikan.js';
|
||||
import { bolt } from './accessories/bolt.js';
|
||||
import type { AvatarAccessoryDef } from './accessory.js';
|
||||
|
||||
export const AVATAR_ACCESSORY_DEFS = [
|
||||
mug,
|
||||
mikan,
|
||||
bolt,
|
||||
] as AvatarAccessoryDef[];
|
||||
|
||||
export function getAccessoryDef(type: string): AvatarAccessoryDef {
|
||||
const def = AVATAR_ACCESSORY_DEFS.find(x => x.id === type) as AvatarAccessoryDef | undefined;
|
||||
if (def == null) {
|
||||
throw new Error(`Unrecognized accessory type: ${type}`);
|
||||
}
|
||||
return def;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as BABYLON from '@babylonjs/core/pure.js';
|
||||
import { ModelExplorer, type Timer } from '../utility.js';
|
||||
import type { AccessorySchemaDef } from 'misskey-world/src/avatars/accessory.js';
|
||||
import type { OptionsSchema } from 'misskey-world/src/mono.js';
|
||||
import type { ConvertedOptions, GetConvertedOptionsSchemaValues } from '../mono.js';
|
||||
|
||||
export type AvatarAccessoryInstance<Options = any> = {
|
||||
onOptionsUpdated?: <K extends keyof Options, V extends Options[K]>(kv: [K, V]) => void;
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
export type SnapshotRenderingHelperWrapper = {
|
||||
updateMesh: (meshes: BABYLON.Mesh[]) => void;
|
||||
reset: () => void;
|
||||
fixParticleSystem: (ps: BABYLON.ParticleSystem) => void;
|
||||
};
|
||||
|
||||
export type AvatarAccessoryDef<Schema extends AccessorySchemaDef = AccessorySchemaDef> = Schema & {
|
||||
path?: (options: string extends keyof Schema['options']['schema'] ? ConvertedOptions : Readonly<GetConvertedOptionsSchemaValues<Schema['options']['schema']>>) => string;
|
||||
createInstance: (args: {
|
||||
scene: BABYLON.Scene;
|
||||
// TODO: snapshot renderingの関心を隠蔽した方が綺麗かもしれない
|
||||
// 例えばmaterialUpdatedというメソッドを用意して内部的にresetを呼ぶなど
|
||||
sr: SnapshotRenderingHelperWrapper;
|
||||
lc: BABYLON.ClusteredLightContainer | null;
|
||||
root: BABYLON.TransformNode;
|
||||
options: string extends keyof Schema['options']['schema'] ? ConvertedOptions : Readonly<GetConvertedOptionsSchemaValues<Schema['options']['schema']>>;
|
||||
model: ModelExplorer;
|
||||
timer: Timer;
|
||||
graphicsQuality: number;
|
||||
reloadModel: () => void;
|
||||
}) => AvatarAccessoryInstance<string extends keyof Schema['options']['schema'] ? ConvertedOptions : GetConvertedOptionsSchemaValues<Schema['options']['schema']>> | Promise<AvatarAccessoryInstance<Schema['options']['schema'] extends undefined ? ConvertedOptions : GetConvertedOptionsSchemaValues<Schema['options']['schema']>>>; // TODO: createInstanceをasyncにするのではなく、別にreadyみたいなものを返させる
|
||||
};
|
||||
|
||||
export function defineAccessorySchema<const OpSc extends OptionsSchema>(def: AccessorySchemaDef<OpSc>): AccessorySchemaDef<OpSc> {
|
||||
return def;
|
||||
}
|
||||
|
||||
export function defineAccessory<const Schema extends AccessorySchemaDef<any>>(schema: Schema, def: Pick<AvatarAccessoryDef<Schema>, 'path' | 'createInstance'>): AvatarAccessoryDef<Schema> {
|
||||
return { ...schema, ...def };
|
||||
}
|
||||
@@ -1,36 +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.RegisterEnginesWebGPUExtensionsEngineVideoTexture();
|
||||
BABYLON.RegisterBufferAlign();
|
||||
BABYLON.RegisterCubeTexture();
|
||||
BABYLON.RegisterStandardMaterial();
|
||||
BABYLON.RegisterRay();
|
||||
BABYLON.RegisterAnimation();
|
||||
BABYLON.RegisterAnimatable();
|
||||
BABYLON.RegisterCollisionCoordinator();
|
||||
BABYLON.RegisterInstancedMesh();
|
||||
BABYLON.RegisterPostProcessRenderPipelineManagerSceneComponent(
|
||||
BABYLON.PostProcessRenderPipelineManager,
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
// structredCloneが遅いため
|
||||
// SEE: http://var.blog.jp/archives/86038606.html
|
||||
// あと、Vue RefをIndexedDBに保存しようとしてstructredCloneを使ったらエラーになった
|
||||
// https://github.com/misskey-dev/misskey/pull/8098#issuecomment-1114144045
|
||||
|
||||
export type Cloneable = string | number | boolean | null | undefined | { [key: string]: Cloneable } | { [key: number]: Cloneable } | { [key: symbol]: Cloneable } | Cloneable[];
|
||||
|
||||
export function deepClone<T extends Cloneable>(x: T): T {
|
||||
if (typeof x === 'object') {
|
||||
if (x === null) return x;
|
||||
if (Array.isArray(x)) return x.map(deepClone) as T;
|
||||
const obj = {} as Record<string | number | symbol, Cloneable>;
|
||||
for (const [k, v] of Object.entries(x)) {
|
||||
obj[k] = v === undefined ? undefined : deepClone(v);
|
||||
}
|
||||
return obj as T;
|
||||
} else {
|
||||
return x;
|
||||
}
|
||||
}
|
||||
@@ -1,270 +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 { FreeCameraManualInput, GRAPHICS_QUALITY, Timer } from './utility.js';
|
||||
import { TIME_MAP } from './utility.js';
|
||||
import { MultiplayEngineBase } from './MultiplayEngineBase.js';
|
||||
import { LobbyEnvManager } from './envs/lobby.js';
|
||||
import type { PlayerContainer, PlayerProfile, PlayerState } from './PlayerContainer.js';
|
||||
import type { WorldEnvManager } from './env.js';
|
||||
|
||||
const IN_WEB_WORKER = typeof window === 'undefined';
|
||||
|
||||
export class WorldEngine extends MultiplayEngineBase<{
|
||||
'changeMyPlayerState': (ctx: PlayerState) => void;
|
||||
'playerPointed': (ctx: { playerId: string; }) => void;
|
||||
'playSfxUrl': (ctx: {
|
||||
url: string;
|
||||
options: {
|
||||
volume: number;
|
||||
playbackRate: number;
|
||||
};
|
||||
}) => void;
|
||||
'loadingProgress': (ctx: { progress: number }) => void;
|
||||
'contextlost': (ctx: { reason: string; message: string; }) => void;
|
||||
}> {
|
||||
public camera: BABYLON.UniversalCamera;
|
||||
private time: 0 | 1 | 2 = 0; // 0: 昼, 1: 夕, 2: 夜
|
||||
public lightContainer: BABYLON.ClusteredLightContainer;
|
||||
public sr: BABYLON.SnapshotRenderingHelper;
|
||||
public gl: BABYLON.GlowLayer | null = null;
|
||||
public timer: Timer = new Timer();
|
||||
public isSitting = false;
|
||||
private cameraHeight = cm(130);
|
||||
private fov: number;
|
||||
private useGlow: boolean;
|
||||
public graphicsQuality: number;
|
||||
private envManager: WorldEnvManager | null = null;
|
||||
private inited = false;
|
||||
private isGodMode = false;
|
||||
|
||||
constructor(options: {
|
||||
babylonEngine: BABYLON.WebGPUEngine;
|
||||
graphicsQuality: number;
|
||||
fps: number | null;
|
||||
antialias: boolean;
|
||||
fov: number;
|
||||
useVirtualJoystick?: boolean;
|
||||
showUsernameOnAvatar: boolean;
|
||||
show2dAvatarOnAvatar: boolean;
|
||||
}) {
|
||||
super({
|
||||
babylonEngine: options.babylonEngine,
|
||||
fps: options.fps,
|
||||
});
|
||||
|
||||
this.graphicsQuality = options.graphicsQuality;
|
||||
this.useGlow = this.graphicsQuality >= GRAPHICS_QUALITY.MEDIUM;
|
||||
this.fov = options.fov;
|
||||
this.showUsernameOnAvatar = options.showUsernameOnAvatar;
|
||||
this.show2dAvatarOnAvatar = options.show2dAvatarOnAvatar;
|
||||
|
||||
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.scene.collisionsEnabled = true;
|
||||
|
||||
this.sr = new BABYLON.SnapshotRenderingHelper(this.scene);
|
||||
|
||||
this.time = TIME_MAP[new Date().getHours() as keyof typeof TIME_MAP];
|
||||
//this.time = TIME_MAP[12 as keyof typeof TIME_MAP];
|
||||
|
||||
this.scene.ambientColor = new BABYLON.Color3(0.9, 0.9, 0.9);
|
||||
|
||||
this.camera = new BABYLON.FreeCamera('camera', new BABYLON.Vector3(0, this.cameraHeight, cm(0)), this.scene);
|
||||
this.camera.minZ = cm(1);
|
||||
this.camera.maxZ = cm(1000);
|
||||
this.camera.fov = this.fov;
|
||||
this.camera.ellipsoid = new BABYLON.Vector3(cm(15), cm(65), cm(15));
|
||||
if (!this.isGodMode) {
|
||||
this.camera.checkCollisions = true;
|
||||
this.camera.applyGravity = true;
|
||||
this.camera.needMoveForGravity = true;
|
||||
}
|
||||
this.camera.inputs.clear();
|
||||
if (options.useVirtualJoystick) {
|
||||
this.camera.inputs.add(new FreeCameraManualInput(this.scene, {
|
||||
moveSensitivity: 0.02 * WORLD_SCALE,
|
||||
rotationSensitivity: 0.0007,
|
||||
isGodMode: this.isGodMode,
|
||||
}));
|
||||
this.camera.inertia = 0.75;
|
||||
} else {
|
||||
this.camera.inputs.add(new FreeCameraManualInput(this.scene, {
|
||||
moveSensitivity: 0.003 * WORLD_SCALE,
|
||||
rotationSensitivity: 0.0003,
|
||||
isGodMode: this.isGodMode,
|
||||
}));
|
||||
}
|
||||
|
||||
this.scene.activeCamera = this.camera;
|
||||
|
||||
this.lightContainer = new BABYLON.ClusteredLightContainer('clustered', [], this.scene);
|
||||
this.lightContainer.maxRange = cm(10000);
|
||||
this.lightContainer.verticalTiles = 32;
|
||||
this.lightContainer.horizontalTiles = 32;
|
||||
this.lightContainer.depthSlices = 32;
|
||||
|
||||
if (this.useGlow) {
|
||||
this.gl = new BABYLON.GlowLayer('glow', this.scene, {
|
||||
//mainTextureFixedSize: 512,
|
||||
blurKernelSize: 64,
|
||||
});
|
||||
this.gl.intensity = 0.5;
|
||||
this.scene.setRenderingAutoClearDepthStencil(this.gl.renderingGroupId, false);
|
||||
this.sr.updateMeshesForEffectLayer(this.gl);
|
||||
}
|
||||
|
||||
if (this.graphicsQuality >= GRAPHICS_QUALITY.HIGH) {
|
||||
const pipeline = new BABYLON.DefaultRenderingPipeline('default', true, this.scene);
|
||||
if (options.antialias) {
|
||||
pipeline.samples = 4;
|
||||
}
|
||||
|
||||
pipeline.bloomEnabled = true;
|
||||
pipeline.bloomThreshold = 0.95;
|
||||
pipeline.bloomWeight = 0.3;
|
||||
pipeline.bloomKernel = 256;
|
||||
pipeline.bloomScale = 2;
|
||||
|
||||
pipeline.sharpenEnabled = true;
|
||||
pipeline.sharpen.edgeAmount = 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
public async init() {
|
||||
await this.loadEnv();
|
||||
|
||||
this.startRenderLoop();
|
||||
|
||||
await this.scene.whenReadyAsync();
|
||||
|
||||
// 必ずシーンが少なくとも1フレームレンダリングがされてから呼ばれるように注意すること。そうしないとタイミングによってはエンジンがクラッシュする
|
||||
this.sr.enableSnapshotRendering();
|
||||
|
||||
this.inputs.on('wheel', (ev) => {
|
||||
if (this.scene.activeCamera === this.camera) {
|
||||
this.camera.fov += ev.deltaY * 0.001;
|
||||
this.camera.fov = Math.max(0.25, Math.min(this.fov, this.camera.fov));
|
||||
}
|
||||
});
|
||||
|
||||
this.inputs.on('zoom', (ev) => {
|
||||
if (this.scene.activeCamera === this.camera) {
|
||||
this.camera.fov += -ev.delta * 0.003;
|
||||
this.camera.fov = Math.max(0.25, Math.min(this.fov, this.camera.fov));
|
||||
}
|
||||
});
|
||||
|
||||
this.inputs.on('click', (ev) => {
|
||||
// TODO: GPUPickerを使いたいが、なぜか一部のメッシュが反応しない
|
||||
const pickingInfo = this.scene.pick(ev.x, ev.y,
|
||||
(m) => m.name.includes('__PICK__') || m.metadata?.isPlayer || (m.isVisible && m.isEnabled() && m.metadata?.furnitureId != null && this.furnitureContainers.has(m.metadata.furnitureId)));
|
||||
|
||||
if (pickingInfo.pickedMesh != null) {
|
||||
const playerId = pickingInfo.pickedMesh.metadata.playerId;
|
||||
if (playerId != null && this.playerContainers.some(c => c.id === playerId)) {
|
||||
const c = this.playerContainers.find(c => c.id === playerId)!;
|
||||
this.look(c.root.position);
|
||||
this.ev('playerPointed', { playerId });
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.inputs.on('pointer', (ev) => {
|
||||
if (this.scene.activeCamera === this.camera) {
|
||||
(this.camera.inputs.attached.manual as FreeCameraManualInput).setRotationVector({ x: ev.x, y: ev.y });
|
||||
}
|
||||
});
|
||||
|
||||
this.timer.setInterval(() => {
|
||||
const camera = this.scene.activeCamera!;
|
||||
const myPos = camera.globalPosition;
|
||||
const myRotation = camera.absoluteRotation.toEulerAngles();
|
||||
this.ev('changeMyPlayerState', {
|
||||
position: [myPos.x, myPos.y, myPos.z],
|
||||
rotation: [myRotation.x, myRotation.y, myRotation.z],
|
||||
});
|
||||
}, 100);
|
||||
|
||||
this.inited = true;
|
||||
}
|
||||
|
||||
private async loadEnv() {
|
||||
const envManager = new LobbyEnvManager(this);
|
||||
|
||||
await envManager.load();
|
||||
envManager.setTime(this.time);
|
||||
|
||||
for (const mat of this.scene.materials) {
|
||||
mat.unfreeze();
|
||||
if (mat instanceof BABYLON.MultiMaterial) {
|
||||
for (const subMat of mat.subMaterials) {
|
||||
if (subMat.metadata?.useEnvMap) subMat.reflectionTexture = envManager.envMapIndoor;
|
||||
}
|
||||
} else {
|
||||
if (mat.metadata?.useEnvMap) mat.reflectionTexture = envManager.envMapIndoor;
|
||||
}
|
||||
}
|
||||
|
||||
this.envManager = envManager;
|
||||
|
||||
this.camera.maxZ = this.envManager.maxCameraZ;
|
||||
}
|
||||
|
||||
public getEnvMap(): BABYLON.CubeTexture | null {
|
||||
return this.envManager?.envMapIndoor ?? null;
|
||||
}
|
||||
|
||||
public cameraMove(vector: { x: number; y: number; }, dash: boolean) {
|
||||
(this.camera.inputs.attached.manual as FreeCameraManualInput).setMoveVector(dash ? { x: vector.x * 3, y: vector.y * 3 } : vector);
|
||||
}
|
||||
|
||||
public cameraJoystickMove(vector: { x: number; y: number; }) {
|
||||
(this.camera.inputs.attached.manual as FreeCameraManualInput).setMoveVector(vector);
|
||||
}
|
||||
|
||||
private playSfxUrl(url: string, options: { volume: number; playbackRate: number }) {
|
||||
this.emit('playSfxUrl', { url, options });
|
||||
}
|
||||
|
||||
public clearPlayers() {
|
||||
this.sr.disableSnapshotRendering();
|
||||
for (const playerContainer of this.playerContainers) {
|
||||
playerContainer.destroy();
|
||||
}
|
||||
this.sr.enableSnapshotRendering();
|
||||
this.playerContainers = [];
|
||||
}
|
||||
|
||||
public updateAvatarDisplayOptions(options: { showUsername: boolean; show2dAvatar: boolean }) {
|
||||
this.showUsernameOnAvatar = options.showUsername;
|
||||
this.show2dAvatarOnAvatar = options.show2dAvatar;
|
||||
|
||||
this.sr.disableSnapshotRendering();
|
||||
for (const playerContainer of this.playerContainers) {
|
||||
playerContainer.updateUserInfoDisplayOptions(options);
|
||||
}
|
||||
this.sr.enableSnapshotRendering();
|
||||
}
|
||||
|
||||
public resize() {
|
||||
this.babylonEngine.resize(true);
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
super.destroy();
|
||||
this.timer.dispose();
|
||||
this.envManager.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,109 +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 type { WorldEngine } from './engine.js';
|
||||
|
||||
export abstract class WorldEnvManager {
|
||||
protected engine: WorldEngine;
|
||||
public abstract envMapIndoor: BABYLON.CubeTexture | null;
|
||||
public abstract maxCameraZ: number;
|
||||
private shadowGenerators: BABYLON.ShadowGenerator[] = [];
|
||||
|
||||
constructor(engine: WorldEngine) {
|
||||
this.engine = engine;
|
||||
}
|
||||
|
||||
abstract load(): Promise<void>;
|
||||
abstract setTime(time: number): void;
|
||||
|
||||
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 (['__COLLISION__'].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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,564 +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 tinycolor from 'tinycolor2';
|
||||
import Hls from 'hls.js';
|
||||
import { findMaterial, GRAPHICS_QUALITY, Timer } from '../utility.js';
|
||||
import { WorldEnvManager } from '../env.js';
|
||||
import { RecyvlingTextGrid, createPlaneUvMapper, randomRange } from '../utility.js';
|
||||
import { Firework } from '../Firework.js';
|
||||
import { getTimezoneOffsetLabel, timezones } from '../timezones.js';
|
||||
import type { WorldEngine } from '../engine.js';
|
||||
|
||||
export class LobbyEnvManager extends WorldEnvManager {
|
||||
private loaderResult: BABYLON.ISceneLoaderAsyncResult | null = null;
|
||||
private meshes: BABYLON.Mesh[] = [];
|
||||
private skybox: BABYLON.Mesh | null = null;
|
||||
private skyboxMat: BABYLON.StandardMaterial | null = null;
|
||||
private sunLight: BABYLON.DirectionalLight | null = null;
|
||||
public envMapIndoor: BABYLON.CubeTexture | null = null;
|
||||
public maxCameraZ = cm(100000);
|
||||
private textMaterial: BABYLON.StandardMaterial | null = null;
|
||||
private textBwMaterial: BABYLON.StandardMaterial | null = null;
|
||||
private translucentTextMaterial: BABYLON.StandardMaterial | null = null;
|
||||
private timer: Timer = new Timer();
|
||||
private tz = timezones.find((tz) => tz.abbrev === 'JST')!;
|
||||
|
||||
constructor(engine: WorldEngine) {
|
||||
super(engine);
|
||||
}
|
||||
|
||||
public async load() {
|
||||
this.engine.camera.position = new BABYLON.Vector3(cm(0), cm(250), cm(3000));
|
||||
|
||||
this.skybox = BABYLON.MeshBuilder.CreateBox('skybox', { size: cm(50000) }, 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;
|
||||
if (this.engine.gl != null) this.engine.gl.addExcludedMesh(this.skybox);
|
||||
|
||||
const ambientLight1 = new BABYLON.HemisphericLight('ambientLight1', new BABYLON.Vector3(0, 1, 0), this.engine.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.engine.scene);
|
||||
ambientLight2.diffuse = new BABYLON.Color3(0.8, 0.9, 1.0);
|
||||
ambientLight2.intensity = 1;
|
||||
//ambientLight.intensity = 0;
|
||||
|
||||
this.sunLight = new BABYLON.DirectionalLight('sunLight', new BABYLON.Vector3(0, -1, 0), this.engine.scene);
|
||||
this.sunLight.position = new BABYLON.Vector3(cm(0), cm(10000), cm(0));
|
||||
this.sunLight.shadowMinZ = cm(1000);
|
||||
this.sunLight.shadowMaxZ = cm(2000);
|
||||
|
||||
this.loaderResult = await BABYLON.ImportMeshAsync('/client-assets/world/envs/lobby/lobby.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;
|
||||
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);
|
||||
this.meshes[0].bakeCurrentTransformIntoVertices();
|
||||
|
||||
for (const mesh of this.meshes) {
|
||||
if (['__COLLISION__'].some(name => mesh.name.includes(name))) continue;
|
||||
mesh.receiveShadows = true;
|
||||
|
||||
this.addShadowCaster(mesh);
|
||||
}
|
||||
|
||||
this.textMaterial = new BABYLON.StandardMaterial('textMaterial', this.engine.scene);
|
||||
this.textMaterial.diffuseTexture = new BABYLON.Texture('/client-assets/world/envs/lobby/chars-black.png', this.engine.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.textBwMaterial = new BABYLON.StandardMaterial('textBwMaterial', this.engine.scene);
|
||||
this.textBwMaterial.emissiveTexture = new BABYLON.Texture('/client-assets/world/envs/lobby/chars-bw.png', this.engine.scene, false, false);
|
||||
this.textBwMaterial.disableLighting = true;
|
||||
this.textBwMaterial.freeze();
|
||||
|
||||
this.translucentTextMaterial = this.textMaterial.clone('translucentTextMaterial');
|
||||
this.translucentTextMaterial.alpha = 0.25;
|
||||
|
||||
{
|
||||
const objet = this.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.engine.scene.beginAnimation(objet, 0, 5000, true);
|
||||
|
||||
this.engine.scene.onAfterAnimationsObservable.add(() => {
|
||||
this.engine.sr.updateMesh([objet, ...objet.getChildMeshes()], false);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
const ring = this.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.engine.scene.beginAnimation(ring, 0, 5000, true);
|
||||
|
||||
this.engine.scene.onAfterAnimationsObservable.add(() => {
|
||||
this.engine.sr.updateMesh([ring, ...ring.getChildMeshes()], false);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
const messageRingRoot = new BABYLON.TransformNode('', this.engine.scene);
|
||||
const messageRing = this.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.textBwMaterial,
|
||||
});
|
||||
|
||||
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.engine.scene.beginAnimation(messageRing, 0, 10000, true);
|
||||
|
||||
this.engine.scene.onAfterAnimationsObservable.add(() => {
|
||||
this.engine.sr.updateMesh([messageRing, ...messageRing.getChildMeshes()], false);
|
||||
});
|
||||
|
||||
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.engine.scene);
|
||||
const messageRing = this.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.engine.scene.beginAnimation(messageRing, 0, 10000, true);
|
||||
|
||||
this.engine.scene.onAfterAnimationsObservable.add(() => {
|
||||
this.engine.sr.updateMesh([messageRing, ...messageRing.getChildMeshes()], false);
|
||||
});
|
||||
|
||||
this.timer.setInterval(() => {
|
||||
text.write(Date.now().toString());
|
||||
}, 10);
|
||||
}
|
||||
|
||||
{
|
||||
const messageRingRoot = new BABYLON.TransformNode('', this.engine.scene);
|
||||
const messageRing = this.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.engine.scene.beginAnimation(messageRing, 0, 10000, true);
|
||||
|
||||
this.engine.scene.onAfterAnimationsObservable.add(() => {
|
||||
this.engine.sr.updateMesh([messageRing, ...messageRing.getChildMeshes()], false);
|
||||
});
|
||||
|
||||
this.timer.setInterval(() => {
|
||||
const now = this.getCurrentTime();
|
||||
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} ${getTimezoneOffsetLabel(this.tz.offset)}`);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
{
|
||||
const messageRingRoot = new BABYLON.TransformNode('', this.engine.scene);
|
||||
const messageRing = this.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.engine.scene.beginAnimation(messageRing, 0, 10000, true);
|
||||
|
||||
this.engine.scene.onAfterAnimationsObservable.add(() => {
|
||||
this.engine.sr.updateMesh([messageRing, ...messageRing.getChildMeshes()], false);
|
||||
});
|
||||
|
||||
this.timer.setInterval(() => {
|
||||
const now = this.getCurrentTime();
|
||||
const years = now.getFullYear().toString();
|
||||
const months = (now.getMonth() + 1).toString().padStart(2, '0');
|
||||
const days = now.getDate().toString().padStart(2, '0');
|
||||
const dayOfWeek = now.getDay();
|
||||
const dayOfWeekStr = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][dayOfWeek];
|
||||
text.write(`${this.tz.abbrev} ${years}/${months}/${days} ${dayOfWeekStr.toUpperCase()}`);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
const spheres = [] as BABYLON.Mesh[];
|
||||
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const sphereRoot = new BABYLON.TransformNode('', this.engine.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.engine.scene);
|
||||
sphere.parent = sphereRoot;
|
||||
sphere.position = new BABYLON.Vector3(cm(0), cm(0), cm(randomRange(2000, 7000)));
|
||||
spheres.push(sphere);
|
||||
|
||||
const mat = new BABYLON.PBRMaterial('', this.engine.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.engine.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.engine.scene.beginAnimation(sphereRoot, 0, speed, true);
|
||||
}
|
||||
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const sphereRoot = new BABYLON.TransformNode('', this.engine.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.engine.scene);
|
||||
sphere.parent = sphereRoot;
|
||||
sphere.position = new BABYLON.Vector3(cm(0), cm(0), cm(randomRange(10000, 15000)));
|
||||
spheres.push(sphere);
|
||||
|
||||
const mat = new BABYLON.PBRMaterial('', this.engine.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.engine.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.engine.scene.beginAnimation(sphereRoot, 0, speed, true);
|
||||
}
|
||||
|
||||
this.engine.scene.onAfterAnimationsObservable.add(() => {
|
||||
this.engine.sr.updateMesh(spheres, false);
|
||||
});
|
||||
|
||||
const adsCountCol = 4;
|
||||
const adsCountRow = 2;
|
||||
const adRoots = [] as BABYLON.TransformNode[];
|
||||
for (let j = 0; j < adsCountRow; j++) {
|
||||
for (let i = 0; i < adsCountCol; i++) {
|
||||
const adRoot = new BABYLON.TransformNode(`ad_${j}_${i}_root`, this.engine.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.engine.scene);
|
||||
adMesh.parent = adRoot;
|
||||
adMesh.position = new BABYLON.Vector3(cm(0), cm(0), cm(7500));
|
||||
|
||||
const tex = new BABYLON.Texture('http://syu-win.local:3000/files/e67bd2ec-a217-4c17-a222-596dcdbd0e57', this.engine.scene);
|
||||
const adMat = new BABYLON.StandardMaterial(`ad_${j}_${i}_mat`, this.engine.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.engine.scene.beginAnimation(adRoot, 0, 15000, true);
|
||||
|
||||
adRoots.push(adRoot);
|
||||
}
|
||||
}
|
||||
|
||||
this.engine.scene.onAfterAnimationsObservable.add(() => {
|
||||
this.engine.sr.updateMesh(adRoots.flatMap(adRoot => [...adRoot.getChildMeshes()]), false);
|
||||
});
|
||||
|
||||
const worldRingH = this.meshes.find(m => m.name.includes('__WORLD_RING_H__'));
|
||||
const worldRingM = this.meshes.find(m => m.name.includes('__WORLD_RING_M__'));
|
||||
|
||||
worldRingH.material.reflectionTexture = null;
|
||||
worldRingM.material.reflectionTexture = null;
|
||||
|
||||
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);
|
||||
this.engine.sr.updateMesh([worldRingH, ...worldRingH.getChildMeshes(), worldRingM, ...worldRingM.getChildMeshes()], false);
|
||||
}, 100);
|
||||
|
||||
const dome = this.meshes.find(m => m.name.includes('__DOME__'));
|
||||
dome.infiniteDistance = true;
|
||||
const domeTexture = new BABYLON.CustomProceduralTexture('texture', '/client-assets/world/envs/lobby/shaders/bg', 4096, this.engine.scene);
|
||||
domeTexture.hasAlpha = true;
|
||||
domeTexture.wrapU = BABYLON.Texture.MIRROR_ADDRESSMODE;
|
||||
domeTexture.wrapV = BABYLON.Texture.MIRROR_ADDRESSMODE;
|
||||
dome.material = new BABYLON.StandardMaterial('sphereMat', this.engine.scene);
|
||||
(dome.material as BABYLON.StandardMaterial).diffuseTexture = domeTexture;
|
||||
(dome.material as BABYLON.StandardMaterial).emissiveColor = new BABYLON.Color3(1, 1, 1);
|
||||
(dome.material as BABYLON.StandardMaterial).disableLighting = true;
|
||||
(dome.material as BABYLON.StandardMaterial).useAlphaFromDiffuseTexture = true;
|
||||
//this.engine.gl!.addExcludedMesh(dome);
|
||||
|
||||
const screenMeshes = this.meshes.filter(m => m.name.includes('__SCREEN__'));
|
||||
const screenMaterial = screenMeshes[0].material as BABYLON.PBRMaterial;
|
||||
|
||||
const videoEl = document.createElement('video');
|
||||
videoEl.crossOrigin = 'anonymous';
|
||||
//videoEl.src = 'http://syu-win.local:3000/files/931c02c3-6238-4c29-9371-06bab78950bb';
|
||||
|
||||
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.engine.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', 0);
|
||||
}
|
||||
});
|
||||
}, 3000);
|
||||
|
||||
const hourHands = this.meshes.filter(m => m.name.includes('__CLOCK_HAND_H__'));
|
||||
const minuteHands = this.meshes.filter(m => m.name.includes('__CLOCK_HAND_M__'));
|
||||
|
||||
this.timer.setInterval(() => {
|
||||
const now = this.getCurrentTime();
|
||||
const hours = now.getHours() % 12;
|
||||
const minutes = now.getMinutes();
|
||||
const hAngle = -(hours / 12) * Math.PI * 2 - (minutes / 60) * (Math.PI * 2 / 12);
|
||||
const mAngle = -(minutes / 60) * Math.PI * 2;
|
||||
|
||||
for (const hourHand of hourHands) hourHand.rotation = new BABYLON.Vector3(0, 0, hAngle);
|
||||
for (const minuteHand of minuteHands) minuteHand.rotation = new BABYLON.Vector3(0, 0, mAngle);
|
||||
|
||||
this.engine.sr.updateMesh([...hourHands, ...minuteHands], false);
|
||||
}, 1000);
|
||||
|
||||
//const emitter = new BABYLON.TransformNode('emitter', this.engine.scene);
|
||||
//emitter.position = new BABYLON.Vector3(0, cm(-1000), 0);
|
||||
//const ps = new BABYLON.ParticleSystem('', 128, this.engine.scene);
|
||||
//ps.particleTexture = new BABYLON.Texture('/client-assets/world/envs/lobby/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();
|
||||
//this.engine.sr.fixParticleSystem(ps);
|
||||
|
||||
const firework = new Firework(this.engine);
|
||||
this.timer.setInterval(() => {
|
||||
firework.launch({
|
||||
position: [randomRange(cm(-5000), cm(5000)), cm(randomRange(1000, 3000)), randomRange(cm(-5000), cm(5000))],
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
this.registerMeshes(this.meshes);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
private getCurrentTime() {
|
||||
const now = new Date();
|
||||
now.setMinutes(now.getMinutes() + now.getTimezoneOffset() + this.tz.offset);
|
||||
return now;
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this.timer.dispose();
|
||||
for (const m of this.meshes) {
|
||||
m.dispose(false, true);
|
||||
}
|
||||
this.skybox?.dispose();
|
||||
this.skyboxMat?.dispose();
|
||||
this.envMapIndoor?.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();
|
||||
}
|
||||
}
|
||||
|
||||
class MessageRing {
|
||||
constructor(mesh: BABYLON.Mesh, scene: BABYLON.Scene, options: { material: BABYLON.StandardMaterial; repeatSeparator: string; }) {
|
||||
const messageRingRoot = new BABYLON.TransformNode('', this.engine.scene);
|
||||
const messageRing = this.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.engine.scene.beginAnimation(messageRing, 0, 10000, true);
|
||||
|
||||
this.timer.setInterval(() => {
|
||||
const now = new Date();
|
||||
const hours = now.getHours().toString().padStart(2, '0');
|
||||
const minutes = now.getMinutes().toString().padStart(2, '0');
|
||||
const seconds = now.getSeconds().toString().padStart(2, '0');
|
||||
text.write(`${hours}:${minutes}:${seconds}`);
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
// ランダムな文字列が生成できればなんでも良い(時系列でソートできるなら尚良)が、とりあえずaidの実装を拝借
|
||||
|
||||
const TIME2000 = 946684800000;
|
||||
let counter = Math.floor(Math.random() * 10000);
|
||||
|
||||
function getTime(time: number): string {
|
||||
time = time - TIME2000;
|
||||
if (time < 0) time = 0;
|
||||
|
||||
return time.toString(36).padStart(8, '0');
|
||||
}
|
||||
|
||||
function getNoise(): string {
|
||||
return counter.toString(36).padStart(2, '0').slice(-2);
|
||||
}
|
||||
|
||||
export function genId(): string {
|
||||
counter++;
|
||||
return getTime(Date.now()) + getNoise();
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { OptionsSchema, NumberOptionSchema, BooleanOptionSchema, StringOptionSchema, ColorOptionSchema, MaterialOptionSchema, LightOptionSchema, EnumOptionSchema, RangeOptionSchema, ImageOptionSchema, SeedOptionSchema } from 'misskey-world/src/mono.js';
|
||||
|
||||
export type RawOptions = Record<string, unknown> & {
|
||||
readonly __brand: unique symbol;
|
||||
};
|
||||
|
||||
export type ConvertedOptions = Record<string, unknown> & {
|
||||
readonly __brand: unique symbol;
|
||||
};
|
||||
|
||||
type RawImageValue<Presets extends string = string> = { type: Presets | null | '_custom_'; driveFileId?: string | null; fit?: 'cover' | 'contain' | 'stretch'; rotation?: 0 | 1 | 2 | 3; };
|
||||
|
||||
type ConvertedImageValue<Presets extends string = string> = { type: Presets | null | '_custom_'; custom?: { url: string; } | null; fit?: 'cover' | 'contain' | 'stretch'; rotation?: 0 | 1 | 2 | 3; };
|
||||
export type GetConvertedOptionsSchemaValues<T extends OptionsSchema> = {
|
||||
[K in keyof T]:
|
||||
T[K] extends NumberOptionSchema ? number :
|
||||
T[K] extends BooleanOptionSchema ? boolean :
|
||||
T[K] extends StringOptionSchema ? string :
|
||||
T[K] extends ColorOptionSchema ? [number, number, number] :
|
||||
T[K] extends MaterialOptionSchema ? { color: [number, number, number]; metallic: number; roughness: number; } :
|
||||
T[K] extends LightOptionSchema ? { color: [number, number, number]; brightness: number; } :
|
||||
T[K] extends EnumOptionSchema ? T[K]['enum'][number]['value'] :
|
||||
T[K] extends RangeOptionSchema ? number :
|
||||
T[K] extends ImageOptionSchema ? ConvertedImageValue<T[K]['presets'][number]['value']> :
|
||||
T[K] extends SeedOptionSchema ? number :
|
||||
never;
|
||||
};
|
||||
|
||||
export function convertRawOptions<OpSc extends OptionsSchema>(schema: OpSc, raw: RawOptions, attachments: { files: { id: string; url: string; }[] }): ConvertedOptions {
|
||||
const converted = {} as ConvertedOptions;
|
||||
for (const record of Object.entries(schema)) {
|
||||
const k = record[0];
|
||||
const v = raw[k];
|
||||
if (record[1].type === 'image') {
|
||||
const _v = v as unknown as RawImageValue;
|
||||
const file = _v.type === '_custom_' ? attachments.files.find(f => f.id === _v.driveFileId) : null;
|
||||
if (file != null && file.url.startsWith('http://syu-win.local:3000/')) { // debug
|
||||
file.url = file.url.replace('http://syu-win.local:3000/', 'https://local-mi.syuilo.dev/');
|
||||
}
|
||||
|
||||
converted[k] = { type: _v.type, custom: file != null ? { url: file.url } : null, fit: _v.fit, rotation: _v.rotation } satisfies ConvertedImageValue;
|
||||
} else {
|
||||
converted[k] = v;
|
||||
}
|
||||
}
|
||||
return converted;
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as BABYLON from '@babylonjs/core/pure.js';
|
||||
import { registerBabylonRuntime } from './babylonRuntime.js';
|
||||
import { WorldEngine } from './engine.js';
|
||||
|
||||
registerBabylonRuntime();
|
||||
|
||||
export async function createWorldEngine(params: {
|
||||
canvas: HTMLCanvasElement;
|
||||
options: {
|
||||
antialias: boolean;
|
||||
resolution: number;
|
||||
fov: number;
|
||||
graphicsQuality: number;
|
||||
fps: number | null;
|
||||
useVirtualJoystick?: boolean;
|
||||
showUsernameOnAvatar: boolean;
|
||||
show2dAvatarOnAvatar: boolean;
|
||||
};
|
||||
}) {
|
||||
const babylonEngine = new BABYLON.WebGPUEngine(params.canvas, { doNotHandleContextLost: true, powerPreference: 'high-performance', antialias: params.options.antialias });
|
||||
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 WorldEngine({
|
||||
babylonEngine: babylonEngine,
|
||||
...params.options,
|
||||
});
|
||||
|
||||
return engine;
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as BABYLON from '@babylonjs/core/pure.js';
|
||||
import { camelToKebab, WORLD_SCALE } from 'misskey-world/src/utility.js';
|
||||
import { scaleMorph, Timer } from '../utility.js';
|
||||
import { convertRawOptions, type ConvertedOptions, type RawOptions } from '../mono.js';
|
||||
import { getFurnitureDef } from './furniture-defs.js';
|
||||
import { ModelManager, SYSTEM_MESH_NAMES } from './utility.js';
|
||||
import type { RoomFurnitureInstance } from './furniture.js';
|
||||
import type { RoomAttachments } from 'misskey-world/src/room/type.js';
|
||||
|
||||
function mergeMeshes(meshes: BABYLON.Mesh[], root: BABYLON.Mesh, hasTexture: boolean) {
|
||||
const excludeMeshes = root.getChildMeshes().filter(m => SYSTEM_MESH_NAMES.some(s => m.name.includes(s)));
|
||||
|
||||
const childMeshes = root.getChildMeshes().filter(m => !excludeMeshes.some(x => x === m) && m.isVisible && !m.isDisposed());
|
||||
|
||||
const toMerge = [] as BABYLON.Mesh[];
|
||||
for (const mesh of childMeshes) {
|
||||
if (mesh instanceof BABYLON.InstancedMesh) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mesh.hasInstances) continue;
|
||||
|
||||
if (mesh instanceof BABYLON.Mesh) {
|
||||
toMerge.push(mesh);
|
||||
}
|
||||
}
|
||||
|
||||
if (toMerge.length <= 1) { // マージ対象が一つしかない状態でマージするのは単純に無駄なのと、babylonのバグが知らないけどなぜか法線が反転する
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const mesh of toMerge) {
|
||||
if (hasTexture) {
|
||||
if (mesh.getVerticesData(BABYLON.VertexBuffer.UVKind) == null) {
|
||||
const vertexCount = mesh.getTotalVertices();
|
||||
const uvs = new Array(vertexCount * 2).fill(0);
|
||||
mesh.setVerticesData(BABYLON.VertexBuffer.UVKind, uvs, false, 2);
|
||||
}
|
||||
if (mesh.getVerticesData(BABYLON.VertexBuffer.UV2Kind) == null) {
|
||||
const vertexCount = mesh.getTotalVertices();
|
||||
const uvs = new Array(vertexCount * 2).fill(0);
|
||||
mesh.setVerticesData(BABYLON.VertexBuffer.UV2Kind, uvs, false, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const merged = BABYLON.Mesh.MergeMeshes(toMerge, true, false, undefined, false, true);
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export class FurnitureContainer {
|
||||
public id: string;
|
||||
public type: string;
|
||||
private options: ConvertedOptions;
|
||||
public root: BABYLON.TransformNode;
|
||||
private subRoot: BABYLON.TransformNode | null = null;
|
||||
public instance: RoomFurnitureInstance | null = null;
|
||||
public model: ModelManager | null = null;
|
||||
private scene: BABYLON.Scene;
|
||||
public registerMeshes: (meshes: BABYLON.Mesh[]) => void = () => {};
|
||||
private sr: BABYLON.SnapshotRenderingHelper;
|
||||
private getIsSrReady: () => boolean;
|
||||
private lightContainer: BABYLON.ClusteredLightContainer;
|
||||
private graphicsQuality: number;
|
||||
private timer: Timer = new Timer();
|
||||
private sitChair: () => void = () => {};
|
||||
public boundingBox: {
|
||||
min: BABYLON.Vector3;
|
||||
max: BABYLON.Vector3;
|
||||
} | null = null;
|
||||
|
||||
constructor(args: {
|
||||
id: string;
|
||||
type: string;
|
||||
options: RawOptions;
|
||||
roomAttachments: RoomAttachments;
|
||||
position: BABYLON.Vector3;
|
||||
rotation: BABYLON.Vector3;
|
||||
sr: BABYLON.SnapshotRenderingHelper;
|
||||
getIsSrReady: () => boolean;
|
||||
lightContainer: BABYLON.ClusteredLightContainer;
|
||||
scene: BABYLON.Scene;
|
||||
graphicsQuality: number;
|
||||
sitChair?: () => void;
|
||||
}) {
|
||||
this.id = args.id;
|
||||
this.type = args.type;
|
||||
const def = getFurnitureDef(this.type);
|
||||
this.options = convertRawOptions(def.options.schema, args.options, args.roomAttachments);
|
||||
this.sr = args.sr;
|
||||
this.getIsSrReady = args.getIsSrReady;
|
||||
this.lightContainer = args.lightContainer;
|
||||
this.scene = args.scene;
|
||||
this.graphicsQuality = args.graphicsQuality;
|
||||
this.root = new BABYLON.TransformNode(`furniture_${args.id}_${args.type}`, this.scene);
|
||||
this.root.position = args.position;
|
||||
this.root.rotation = args.rotation;
|
||||
if (args.sitChair != null) this.sitChair = args.sitChair;
|
||||
}
|
||||
|
||||
public async load() {
|
||||
const def = getFurnitureDef(this.type);
|
||||
|
||||
const filePath = def.path != null ? `/client-assets/world/objects/${def.path(this.options)}.glb` : `/client-assets/world/objects/${camelToKebab(this.type)}/${camelToKebab(this.type)}.glb`;
|
||||
const loaderResult = await BABYLON.LoadAssetContainerAsync(filePath, this.scene);
|
||||
|
||||
// babylonによって自動で追加される右手系変換用ノード
|
||||
const subRootMesh = loaderResult.meshes[0] as BABYLON.Mesh;
|
||||
|
||||
// 不要なUVを掃除
|
||||
if (!def.hasTexture) {
|
||||
for (const m of loaderResult.meshes) {
|
||||
if (m.geometry != null) {
|
||||
m.geometry.removeVerticesData(BABYLON.VertexBuffer.UVKind);
|
||||
m.geometry.removeVerticesData(BABYLON.VertexBuffer.UV2Kind);
|
||||
m.geometry.removeVerticesData(BABYLON.VertexBuffer.UV3Kind);
|
||||
m.geometry.removeVerticesData(BABYLON.VertexBuffer.UV4Kind);
|
||||
m.geometry.removeVerticesData(BABYLON.VertexBuffer.UV5Kind);
|
||||
m.geometry.removeVerticesData(BABYLON.VertexBuffer.UV6Kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (def.canPreMeshesMerging) {
|
||||
const merged = mergeMeshes(loaderResult.meshes, subRootMesh, def.hasTexture);
|
||||
if (merged != null) {
|
||||
merged.setParent(subRootMesh);
|
||||
merged.name = 'preMerged';
|
||||
|
||||
merged.material.freeze();
|
||||
if (merged.material instanceof BABYLON.MultiMaterial) {
|
||||
for (const subMat of merged.material.subMaterials) {
|
||||
subMat.freeze();
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: 再帰的にする
|
||||
for (const m of loaderResult.transformNodes) {
|
||||
if (m.getChildren().length === 0) {
|
||||
m.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// meshじゃなくtransform nodeにしてパフォーマンス向上
|
||||
this.subRoot = new BABYLON.TransformNode('__root__', this.scene);
|
||||
this.subRoot.parent = this.root;
|
||||
this.subRoot.scaling.x = -1;
|
||||
this.subRoot.scaling = this.subRoot.scaling.scale(WORLD_SCALE);// cmをmに
|
||||
|
||||
for (const m of subRootMesh.getChildren()) {
|
||||
if (m.parent === subRootMesh) {
|
||||
m.parent = this.subRoot;
|
||||
}
|
||||
}
|
||||
|
||||
subRootMesh.dispose();
|
||||
|
||||
this.registerMeshes(this.subRoot.getChildMeshes());
|
||||
|
||||
this.model = new ModelManager(this.subRoot, loaderResult.meshes.filter(m => !m.isDisposed() && m.name !== '__root__'), def.hasTexture, (meshes) => {
|
||||
this.registerMeshes(meshes);
|
||||
});
|
||||
|
||||
this.instance = await def.createInstance({
|
||||
scene: this.scene,
|
||||
sr: {
|
||||
updateMesh: (mesh) => {
|
||||
if (!this.getIsSrReady()) return;
|
||||
this.sr.updateMesh(mesh);
|
||||
},
|
||||
reset: () => {
|
||||
if (!this.getIsSrReady()) return;
|
||||
this.sr.disableSnapshotRendering();
|
||||
this.sr.enableSnapshotRendering();
|
||||
},
|
||||
fixParticleSystem: (ps) => this.sr.fixParticleSystem(ps),
|
||||
},
|
||||
lc: this.lightContainer,
|
||||
root: this.root,
|
||||
options: this.options,
|
||||
model: this.model!,
|
||||
id: this.id,
|
||||
timer: this.timer,
|
||||
graphicsQuality: this.graphicsQuality,
|
||||
reloadModel: () => {
|
||||
this.reload();
|
||||
},
|
||||
sitChair: () => {
|
||||
this.sitChair();
|
||||
},
|
||||
stickyMarkerMeshUpdated: (mesh) => {
|
||||
// TODO
|
||||
//// stickyな子の位置を更新
|
||||
//if (mesh.name.includes('__TOP__')) {
|
||||
// mesh.unfreezeWorldMatrix();
|
||||
// mesh.computeWorldMatrix(true);
|
||||
// const updateChildStickyObjectPosition = (furnitureId: string) => {
|
||||
// const stickyFurnitureIds = Array.from(this.roomState.installedFurnitures.filter(o => o.sticky === furnitureId)).map(o => o.id);
|
||||
// for (const soid of stickyFurnitureIds) {
|
||||
// const soMesh = this.objectEntities.get(soid)!.rootMesh;
|
||||
// soMesh.unfreezeWorldMatrix();
|
||||
// for (const m of soMesh.getChildMeshes()) {
|
||||
// m.unfreezeWorldMatrix();
|
||||
// }
|
||||
// console.log(mesh.getAbsolutePosition().y);
|
||||
// soMesh.position.y = mesh.getAbsolutePosition().y;
|
||||
// updateChildStickyObjectPosition(soid);
|
||||
// }
|
||||
// };
|
||||
// updateChildStickyObjectPosition(args.id);
|
||||
//}
|
||||
},
|
||||
});
|
||||
|
||||
this.instance.onInited?.();
|
||||
|
||||
this.calcBoundingBox();
|
||||
}
|
||||
|
||||
public calcBoundingBox() {
|
||||
// TODO: モーフ最大適用後のサイズが取得されてしまうのを直す
|
||||
this.boundingBox = this.subRoot.getHierarchyBoundingVectors(true);
|
||||
}
|
||||
|
||||
public interact(iid: string | null = null) {
|
||||
if (this.instance == null) return;
|
||||
if (iid == null) {
|
||||
if (this.instance.primaryInteraction != null) {
|
||||
this.instance.interactions[this.instance.primaryInteraction].fn();
|
||||
}
|
||||
} else {
|
||||
this.instance.interactions[iid].fn();
|
||||
}
|
||||
}
|
||||
|
||||
public async reload() {
|
||||
this.timer.dispose();
|
||||
this.instance?.dispose?.();
|
||||
this.instance = null;
|
||||
this.model = null;
|
||||
this.subRoot?.dispose();
|
||||
this.root.removeChild(this.subRoot);
|
||||
this.scene.removeTransformNode(this.subRoot);
|
||||
|
||||
this.timer = new Timer();
|
||||
|
||||
await this.load();
|
||||
|
||||
this.sr.disableSnapshotRendering();
|
||||
this.sr.enableSnapshotRendering();
|
||||
}
|
||||
|
||||
public optionsUpdated(options: RawOptions, key: string, value: any, roomAttachments: RoomAttachments) {
|
||||
if (this.instance == null) return;
|
||||
const def = getFurnitureDef(this.type);
|
||||
const convertedOptions = convertRawOptions(def.options.schema, options, roomAttachments);
|
||||
this.options[key] = convertedOptions[key]; // 参照を切れさせないようにプロパティ個別にmutate
|
||||
this.sr.disableSnapshotRendering();
|
||||
this.instance.onOptionsUpdated?.([key, this.options[key]]);
|
||||
this.sr.enableSnapshotRendering();
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.sr.disableSnapshotRendering();
|
||||
this.timer.dispose();
|
||||
this.instance?.dispose?.();
|
||||
this.subRoot.dispose();
|
||||
this.root.dispose();
|
||||
this.scene.removeTransformNode(this.root);
|
||||
this.sr.enableSnapshotRendering();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,121 +0,0 @@
|
||||
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as BABYLON from '@babylonjs/core/pure.js';
|
||||
import { SYSTEM_HEYA_MESH_NAMES } from './utility.js';
|
||||
import type { RoomEngine } from './engine.js';
|
||||
|
||||
export abstract class RoomEnvManager<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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 { RoomEnvManager } from '../env.js';
|
||||
import type { RoomEngine } from '../engine.js';
|
||||
import type { CustomMadoriEnvOptions } from 'misskey-world/src/room/env.js';
|
||||
|
||||
export class CustomMadoriEnvManager extends RoomEnvManager<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();
|
||||
}
|
||||
}
|
||||
@@ -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 { RoomEnvManager } from '../env.js';
|
||||
import type { RoomEngine } from '../engine.js';
|
||||
import type { JapaneseEnvOptions } from 'misskey-world/src/room/env.js';
|
||||
|
||||
export class JapaneseEnvManager extends RoomEnvManager<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();
|
||||
}
|
||||
}
|
||||
@@ -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 { RoomEnvManager } from '../env.js';
|
||||
import type { RoomEngine } from '../engine.js';
|
||||
import type { MuseumEnvOptions } from 'misskey-world/src/room/env.js';
|
||||
|
||||
export class MuseumEnvManager extends RoomEnvManager<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();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user