├── .gitignore ├── setup.lnk ├── start.lnk ├── stop.lnk ├── regist.lnk ├── unregist.lnk ├── utils ├── stop.ps1 ├── setup.ps1 ├── start.ps1 ├── unregist.ps1 ├── regist_lnk.ps1 ├── unregist_lnk.ps1 └── regist.ps1 ├── src ├── state.ts ├── tags.ts ├── main.ts ├── oscServer.ts ├── config.ts └── logReader.ts ├── LICENSE ├── package.json ├── MakerNotes.schema.json ├── README.md └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | built/ 3 | config.json -------------------------------------------------------------------------------- /setup.lnk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m-hayabusa/VRChat-Exif-Writer/HEAD/setup.lnk -------------------------------------------------------------------------------- /start.lnk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m-hayabusa/VRChat-Exif-Writer/HEAD/start.lnk -------------------------------------------------------------------------------- /stop.lnk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m-hayabusa/VRChat-Exif-Writer/HEAD/stop.lnk -------------------------------------------------------------------------------- /regist.lnk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m-hayabusa/VRChat-Exif-Writer/HEAD/regist.lnk -------------------------------------------------------------------------------- /unregist.lnk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m-hayabusa/VRChat-Exif-Writer/HEAD/unregist.lnk -------------------------------------------------------------------------------- /utils/stop.ps1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m-hayabusa/VRChat-Exif-Writer/HEAD/utils/stop.ps1 -------------------------------------------------------------------------------- /utils/setup.ps1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m-hayabusa/VRChat-Exif-Writer/HEAD/utils/setup.ps1 -------------------------------------------------------------------------------- /utils/start.ps1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m-hayabusa/VRChat-Exif-Writer/HEAD/utils/start.ps1 -------------------------------------------------------------------------------- /utils/unregist.ps1: -------------------------------------------------------------------------------- 1 | Unregister-ScheduledTask -Confirm:$false -TaskName VRChat-Exif-Writer 2 | Start-Sleep -s 1 -------------------------------------------------------------------------------- /utils/regist_lnk.ps1: -------------------------------------------------------------------------------- 1 | powershell -C "Start-Process powershell -Verb runAs -ArgumentList @('-ExecutionPolicy','Unrestricted','-C',`"`$((Get-Location).Path + '\utils\regist.ps1')`")" -------------------------------------------------------------------------------- /utils/unregist_lnk.ps1: -------------------------------------------------------------------------------- 1 | powershell -C "Start-Process powershell -Verb runAs -ArgumentList @('-ExecutionPolicy','Unrestricted','-C',`"`$((Get-Location).Path + '\utils\unregist.ps1')`")" -------------------------------------------------------------------------------- /src/state.ts: -------------------------------------------------------------------------------- 1 | import { config } from './config'; 2 | import { RoomInfo } from './tags'; 3 | 4 | export class State { 5 | static isVL2Enabled = false; 6 | static focalLength = config.focalDefault; 7 | static apertureValue = config.apertureDefault; 8 | static exposureIndex = config.exposureDefault; 9 | static roomInfo = new RoomInfo(); 10 | static players: string[] = []; 11 | static restart = false; //TODO: Remove this 12 | } -------------------------------------------------------------------------------- /utils/regist.ps1: -------------------------------------------------------------------------------- 1 | Set-Location $PSScriptRoot\.. 2 | 3 | $tri = New-ScheduledTaskTrigger -AtLogOn -RandomDelay $(New-TimeSpan -Minutes 1) -User $((Get-WMIObject -class Win32_ComputerSystem).UserName) 4 | $pri = New-ScheduledTaskPrincipal -LogonType S4U -RunLevel Limited -UserId ((Get-WMIObject -class Win32_ComputerSystem).UserName) 5 | $act = New-ScheduledTaskAction -WorkingDirectory $(Get-Location) -Execute """$((Get-Command Node).Source)""" -Argument "built/main.js" 6 | 7 | Register-ScheduledTask -TaskName VRChat-Exif-Writer -TaskPath nekomimiStudio -Trigger $tri -Principal $pri -Action $act -Force 8 | Start-Sleep -s 1 -------------------------------------------------------------------------------- /src/tags.ts: -------------------------------------------------------------------------------- 1 | export class MediaTag { 2 | prefix = "-"; 3 | tag: string; 4 | data: string; 5 | constructor(tag: string, data: string) { 6 | this.tag = tag; 7 | this.data = data; 8 | } 9 | toString(): string { 10 | return `${this.prefix}:${this.tag}=${this.data}` 11 | } 12 | } 13 | 14 | export class RoomInfo { 15 | world_id: string | undefined; 16 | world_name: string | undefined; 17 | permission: string | undefined; 18 | organizer: string | undefined; 19 | 20 | constructor(world_id?: string, world_name?: string, permission?: string, orgnizer?: string) { 21 | this.world_id = world_id; 22 | this.world_name = world_name; 23 | this.permission = permission; 24 | this.organizer = orgnizer; 25 | } 26 | } 27 | 28 | export class MakerNotes { 29 | room: RoomInfo; 30 | players: string[]; 31 | constructor(room: RoomInfo, players: string[]) { 32 | this.room = room; 33 | this.players = players; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 hayabusa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vrchat-exif-writer", 3 | "type": "commonjs", 4 | "version": "0.0.1", 5 | "description": "add EXIF tag to VRChat Pics.", 6 | "repository": { 7 | "type": "github", 8 | "url": "https://github.com/m-hayabusa/VRChat-Exif-Writer.git" 9 | }, 10 | "main": "main.js", 11 | "scripts": { 12 | "start": "node built/main.js", 13 | "tsc": "tsc", 14 | "unregist": "powershell -C \"Start-Process powershell -Verb runAs -ArgumentList @('-ExecutionPolicy','Unrestricted','-C',\"$((Get-Location).Path + '\\utils\\unregist.ps1')\")\"", 15 | "regist": "powershell -C \"Start-Process powershell -Verb runAs -ArgumentList @('-ExecutionPolicy','Unrestricted','-C',\"$((Get-Location).Path + '\\utils\\regist.ps1')\")\"" 16 | }, 17 | "author": "m-hayabusa (https://mewl.me/@mewl)", 18 | "license": "MIT", 19 | "dependencies": { 20 | "exiftool-vendored": "^21.2.0", 21 | "node-osc": "^8.0.3", 22 | "sharp": "^0.31.3", 23 | "tail": "^2.2.6", 24 | "typescript": "^4.9.5" 25 | }, 26 | "devDependencies": { 27 | "@types/node": "^18.14.6", 28 | "@types/node-osc": "^6.0.0", 29 | "@types/sharp": "^0.31.1", 30 | "@types/tail": "^2.2.1" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /MakerNotes.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "type": "object", 4 | "properties": { 5 | "room": { 6 | "type": "object", 7 | "properties": { 8 | "world_id": { 9 | "type": "string", 10 | "pattern": "wrld_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" 11 | }, 12 | "world_name": { 13 | "type": "string" 14 | }, 15 | "permission": { 16 | "type": "string", 17 | "enum": [ 18 | "private", 19 | "private+", 20 | "friends", 21 | "hidden", 22 | "group", 23 | "public" 24 | ] 25 | }, 26 | "organizer": { 27 | "type": "string", 28 | "pattern": "(usr|grp)_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" 29 | } 30 | }, 31 | "required": [ 32 | "world_id", 33 | "world_name", 34 | "permission" 35 | ] 36 | }, 37 | "players": { 38 | "type": "array", 39 | "items": [ 40 | { 41 | "type": "string" 42 | } 43 | ] 44 | } 45 | }, 46 | "required": [ 47 | "room", 48 | "players" 49 | ] 50 | } -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'child_process'; 2 | import * as fs from 'fs'; 3 | import os from 'os'; 4 | import LogReader from './logReader'; 5 | import OscServer from './oscServer'; 6 | import { State } from './state'; 7 | 8 | async function checkRunning() { 9 | return new Promise(res => { 10 | if (fs.existsSync(`${os.tmpdir()}/VRChat-Exif-Writer.pid`)) { 11 | const pid = parseInt(fs.readFileSync(`${os.tmpdir()}/VRChat-Exif-Writer.pid`).toString()); 12 | exec(process.platform == "win32" ? `powershell.exe -C \"Get-Process -Id ${pid}\"` : `ps --no-headers -p ${pid}`, (error, stdout, stderr) => { 13 | if (error?.code != 1) { 14 | throw new Error("Found Another Process"); 15 | } else { 16 | res(); 17 | fs.writeFileSync(`${os.tmpdir()}/VRChat-Exif-Writer.pid`, process.pid.toString()); 18 | } 19 | }); 20 | } else { 21 | res(); 22 | } 23 | }); 24 | } 25 | 26 | async function main() { 27 | await checkRunning(); 28 | 29 | const log = new LogReader(); 30 | const osc = new OscServer(); 31 | let running = false; 32 | 33 | osc.listen(); 34 | const waitLoop = setInterval(() => { 35 | exec(process.platform == "win32" ? "powershell.exe -C \"(Get-Process -Name VRChat | Measure-Object).Count\"" : "ps -A|grep VRChat|wc -l", (error, stdout, stderr) => { 36 | if (parseInt(stdout) >= 1 && !State.restart) { 37 | if (!running) { 38 | running = true; 39 | console.log("VRChat: Start"); 40 | log.open(); 41 | } 42 | } else { 43 | running = false; 44 | State.restart = false; 45 | log.close(); 46 | console.log("Waiting for VRChat..."); 47 | } 48 | }); 49 | }, 5000); 50 | } 51 | 52 | main(); -------------------------------------------------------------------------------- /src/oscServer.ts: -------------------------------------------------------------------------------- 1 | import { Server } from 'node-osc'; 2 | import { State } from './state'; 3 | import { config } from './config'; 4 | 5 | export default class OscServer { 6 | 7 | oscServer: Server | undefined; 8 | 9 | close() { 10 | this.oscServer?.close(); 11 | } 12 | listen() { 13 | this.oscServer = new Server(config.listenPort, config.listenAddress, () => { 14 | // console.log('OSC Server is listening'); 15 | }); 16 | 17 | this.oscServer.on('message', (msg) => { 18 | const path = msg[0]; 19 | const val = parseFloat(msg[1] as string); 20 | 21 | if (path === "/avatar/parameters/VirtualLens2_Control") { 22 | if (val == 193) { 23 | State.isVL2Enabled = true; 24 | } else if (val == 192) { 25 | State.isVL2Enabled = false; 26 | } 27 | } 28 | 29 | if (path === "/avatar/parameters/VirtualLens2_Zoom") { 30 | if (val === 0) 31 | State.focalLength = Infinity; 32 | else 33 | State.focalLength = config.focalMin * Math.exp(val * Math.log(config.focalMax / config.focalMin)); 34 | } 35 | 36 | if (path === "/avatar/parameters/VirtualLens2_Aperture") { 37 | State.apertureValue = config.apertureMin * Math.exp(val * Math.log(config.apertureMax / config.apertureMin)); 38 | } 39 | 40 | if (path === "/avatar/parameters/VirtualLens2_Exposure") { 41 | State.exposureIndex = (2 * val - 1) * config.exposureRange; 42 | } 43 | 44 | if (path === "/avatar/change") { 45 | State.focalLength = config.focalDefault; 46 | State.apertureValue = config.apertureDefault; 47 | State.exposureIndex = config.exposureDefault; 48 | State.isVL2Enabled = false; 49 | } 50 | }); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import sharp from 'sharp'; 3 | 4 | class Config { 5 | constructor() { 6 | let configFile: Config | undefined; 7 | try { 8 | configFile = JSON.parse(fs.readFileSync("./config.json").toString()) as Config; 9 | } catch (e) { 10 | fs.writeFileSync("./config.json", JSON.stringify(this, undefined, " ")); 11 | configFile = undefined; 12 | } 13 | 14 | this.focalMin = configFile?.focalMin ? configFile.focalMin : 12; 15 | this.focalMax = configFile?.focalMax ? configFile.focalMax : 300; 16 | this.focalDefault = configFile?.focalDefault ? configFile.focalDefault : 50; 17 | 18 | this.apertureMin = configFile?.apertureMin ? configFile.apertureMin : 22; 19 | this.apertureMax = configFile?.apertureMax ? configFile.apertureMax : 1; 20 | 21 | this.apertureDefault = configFile?.apertureDefault ? configFile?.apertureDefault : this.apertureMin; 22 | 23 | this.exposureRange = configFile?.exposureRange ? configFile.exposureRange : 3; 24 | this.exposureDefault = configFile?.exposureDefault ? configFile.exposureDefault : 0; 25 | 26 | this.listenPort = configFile?.listenPort ? configFile.listenPort : 9001; 27 | this.listenAddress = configFile?.listenAddress ? configFile.listenAddress : "127.0.0.1"; 28 | 29 | this.destDir = configFile?.destDir ? configFile?.destDir : ""; 30 | this.compressFormat = configFile?.compressFormat ? configFile?.compressFormat : ""; 31 | this.compressOptions = configFile?.compressOptions ? configFile?.compressOptions : {}; 32 | 33 | fs.writeFileSync("./config.json", JSON.stringify(this, undefined, " ")); 34 | } 35 | 36 | focalMin: number; 37 | focalMax: number; 38 | focalDefault: number; 39 | 40 | apertureMin: number; 41 | apertureMax: number; 42 | apertureDefault: number; 43 | 44 | exposureRange: number; 45 | exposureDefault: number; 46 | 47 | listenPort: number; 48 | listenAddress: string; 49 | 50 | destDir: string; 51 | compressFormat: keyof sharp.FormatEnum | ""; 52 | compressOptions: sharp.OutputOptions | sharp.JpegOptions | sharp.PngOptions | sharp.WebpOptions | sharp.AvifOptions | sharp.HeifOptions | sharp.JxlOptions | sharp.GifOptions | sharp.Jp2Options | sharp.TiffOptions; 53 | } 54 | 55 | export const config = new Config(); 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | https://github.com/m-hayabusa/VRCImageHelper を使ってください (VRChat-Exif-Writerは廃止予定) 2 | 3 | # VRChat-Exif-Writer 4 | 5 | VRChatのキャプチャ画像にEXIFタグで 6 | 7 | * `DateTimeOriginal` 撮影時刻 8 | * `ImageDescription` ワールド名とそのインスタンスにいたプレイヤー名 9 | * `MakerNotes` (`MakerNotes.schema.json`の構造のJSONをBase64でエンコードしたもの) 10 | 11 | を書き込みます 12 | 13 | VirtualLens2が有効な場合はさらに 14 | 15 | * `Focal Length` 焦点距離 16 | * `FNumber` F値 17 | * `ExposureIndex` 露出インデックス 18 | 19 | も書き込まれます 20 | 21 | * https://github.com/m-hayabusa/picturama/releases を使うとそれらの情報を見れます 22 | * 他の画像まわりのツール (特に、ログファイルを監視しファイルを移動するタイプのもの) とはおそらく干渉します 23 | * UDP#9001を占有します (OSCメッセージを受信して使うVRC拡張とは共存できません) 24 | * Windows/Linux環境で動きます 25 | 26 | # インストール 27 | ## PowerShellスクリプト (推奨) / Windows 28 | `winget`を利用してNode.JSとGitをインストールした後、`%LocalAppData%\Programs\VRChat-Exif-Writer`にこのアプリケーションを配置するスクリプトです。 29 | 30 | 1. スタートメニューに `powershell` と入力してEnter 31 | 2. 以下の1行をコピーし、1.で開いたウィンドウに貼り付ける 32 | ``` 33 | Invoke-Expression ($([System.Text.Encoding]::GetEncoding("Shift_JIS").GetString((Invoke-WebRequest "https://raw.githubusercontent.com/m-hayabusa/VRChat-Exif-Writer/main/utils/setup.ps1" -UseBasicParsing).RawContentStream.GetBuffer())) -replace "\u0000","") 34 | ``` 35 | 3. 表示内容に従って操作する 36 | 4. `セットアップが終了しました。`と出たら、ウィンドウを閉じる 37 | 38 | ## 手動でセットアップする場合 39 | Node.jsをインストールしてください。 40 | Gitはなくてもセットアップできますが、更新がしやすいので利用することをおすすめします。 41 | 42 | ### Node.js 43 | Node.jsならびにnpmのインストールが必要です。 44 | 以下よりLTS版のダウンロード、インストールを行ってください。 45 | 46 | [ダウンロード | Node.js](https://nodejs.org/ja/download/) 47 | 48 | ### Git 49 | Gitを使ってダウンロードすると更新がしやすいのでおすすめです。 50 | 以下よりダウンロード、インストールを行ってください。 51 | 52 | [Git for Windows: https://gitforwindows.org/](https://gitforwindows.org/) 53 | 54 | Gitをインストールしない場合、右上 Code から Download ZIP し、そのZIPファイルを展開してからフォルダを開いてエクスプローラのアドレスバーに `cmd` と入力、下記 `git clone...` とその下の行を飛ばして3行目から実行してください。 55 | 56 | ### VRChat-Exif-Writer 57 | スタートメニューに`cmd`と入力し、Enterで起動してください。 58 | 表示されたウィンドウに以下を入力してください: 59 | ```cmd 60 | git clone -b main https://github.com/m-hayabusa/VRChat-Exif-Writer.git 61 | cd VRChat-Exif-Writer 62 | npm install 63 | npm run tsc 64 | npm run start 65 | ``` 66 | (このウィンドウはそのまま放置してVRChatを起動する) 67 | 68 | `npm run start`を実行すると以下のような警告が表示される場合があります。 69 | この場合はそのまま`アクセスを許可する`を選択してください。 70 | image 71 | #### 自動起動スクリプトの登録 72 | インストール先のフォルダにあるショートカット `regist` をダブルクリックして開くと、管理者権限を要求するプロンプトが表示されます。 73 | 許可すると、ログインした際に自動で起動するようになります。 74 | #### 自動起動スクリプトの削除 75 | インストール先のフォルダにあるショートカット `unregist` をダブルクリックして開くと、管理者権限を要求するプロンプトが表示されます。 76 | 許可すると、自動で起動しなくなります。 77 | 78 | ## 手動でセットアップする場合 / Linux 79 | Node.JSとGitをインストールしてください。 80 | 81 | Linux(Steam Deck等)で使用する場合、Node.JSに加えて別途perlのインストールが必要になります。 82 | (ほとんどの環境ですでにインストールされていると思います) 83 | ディストリビューションごとに適切なパッケージをインストールしてください。 84 | Node.JSはバージョン管理ツール(`nvm`や`n`のような)を利用してインストールすることをおすすめします。 85 | 86 | Linuxの場合、ターミナルを起動し、以下のコマンドを入力します。 87 | (先頭の$は不要です) 88 | 89 | ```shell 90 | $ git clone -b main https://github.com/m-hayabusa/VRChat-Exif-Writer.git 91 | $ cd VRChat-Exif-Writer 92 | $ npm install 93 | $ npm run tsc 94 | $ npm run start 95 | # Start VRChat 96 | ``` 97 | 98 | また、VRChatのインストールパスがデフォルトでない場合、別途VRChatインストール先の`compatdata`ディレクトリを環境変数`STEAM_COMPAT_DATA_PATH`に指定する必要があります。 99 | 100 | # 更新 101 | ## Windowsの場合 102 | エクスプローラーでVRChat-Exif-Writerのフォルダを開き、`setup`をダブルクリックし、表示されたウィンドウの内容に従って操作してください。 103 | `setup`が見あたらない場合、エクスプローラーのアドレスバーに`git pull`と入力してみてください。 104 | 105 | ## Linuxの場合 106 | VRChat-Exif-Writerのディレクトリで 107 | ``` 108 | $ git pull 109 | $ npm install 110 | $ npm run tsc 111 | ``` 112 | 113 | # 設定 114 | VRChat-Exif-Writerのディレクトリにある、config.jsonを編集してください 115 | 存在しない場合、一回起動すると生成されるはずです 116 | ``` 117 | { 118 | "focalMin": 12, // VirtualLens2 の Min Focal Length 119 | "focalMax": 300, // VirtualLens2 の Max Focal Length 120 | "focalDefault": 50, // VirtualLens2 の Default Focal Length 121 | "apertureMin": 22, // VirtualLens2 の Min F Number 122 | "apertureMax": 1, // VirtualLens2 の Max F Number 123 | "apertureDefault": 22, // VirtualLens2 の Default F Number 124 | "exposureRange": 3, // VirtualLens2 の Exposure Range 125 | "exposureDefault": 0, // VirtualLens2 の Default Exposure 126 | "listenPort": 9001, // VRChatがOSCを送信するUDPポート 127 | "destDir": "", // 保存先ディレクトリ 128 | // "D:/VRCImage" にすると D:/VRCImage/2023-02 のようなディレクトリに保存される (空なら移動しない) 129 | "compressFormat": "", // https://sharp.pixelplumbing.com/api-output#toformat のformatに指定できる文字列 (空なら変換しない) 130 | // たとえば "jpeg" とか "webp" 、"avif" など 131 | "compressOptions": {} // https://sharp.pixelplumbing.com/api-output#toformat のoptionsに指定できるオブジェクト 132 | // たとえば { "quality": 90, "effort": 5} のように 133 | } 134 | ``` 135 | VL2導入時に各パラメータを設定していない / VRChatの起動オプションでOSCの宛先ポートを変更していない 場合、デフォルト設定のままで問題ないはずです 136 | -------------------------------------------------------------------------------- /src/logReader.ts: -------------------------------------------------------------------------------- 1 | import { Tail } from 'tail'; 2 | import * as fs from 'fs'; 3 | import path from 'path'; 4 | import { exiftool } from 'exiftool-vendored'; 5 | 6 | import { State } from './state'; 7 | import { config } from './config'; 8 | import { MediaTag, RoomInfo, MakerNotes } from './tags'; 9 | import sharp from 'sharp'; 10 | 11 | const compatdata_path = process.platform == "win32" ? "" : process.env.STEAM_COMPAT_DATA_PATH == undefined ? `${process.env["HOME"]}/.local/share/Steam/steamapps/compatdata/` : `${process.env.STEAM_COMPAT_DATA_PATH}` 12 | 13 | export default class LogReader { 14 | tail: Tail | undefined; 15 | logFile: string = ""; 16 | private logReadLoop: NodeJS.Timer | undefined; 17 | private logReopenLoop: NodeJS.Timer | undefined; 18 | 19 | close() { 20 | clearInterval(this.logReadLoop); 21 | clearInterval(this.logReopenLoop); 22 | this.logReadLoop = undefined; 23 | this.logReopenLoop = undefined; 24 | this.logFile = ""; 25 | this.tail?.unwatch(); 26 | } 27 | 28 | reflesh() { 29 | fs.lstat(this.logFile, () => { }); 30 | } 31 | 32 | open(force: boolean = false) { 33 | const logDir = process.platform == "win32" ? `${process.env.APPDATA}\\..\\LocalLow\\VRChat\\VRChat\\` : `${compatdata_path}/438100/pfx/drive_c/users/steamuser/AppData/LocalLow/VRChat/VRChat/`; 34 | const logFile = logDir + (fs.readdirSync(logDir) 35 | .filter(e => e.startsWith("output_log_")) 36 | .map(e => ({ f: e, t: fs.lstatSync(logDir + e).mtime.getTime() })) 37 | .sort((a, b) => b.t - a.t))[0].f; 38 | 39 | if (this.logFile === logFile && !force) { 40 | return; 41 | } 42 | 43 | this.logFile = logFile; 44 | 45 | if (!this.logReopenLoop) { 46 | this.logReopenLoop = setInterval(() => { 47 | this.open(false); 48 | }, 5000); 49 | } 50 | 51 | if (!this.logReadLoop) { 52 | this.logReadLoop = setInterval(() => { 53 | this.reflesh(); 54 | }, 500); 55 | } 56 | 57 | this.tail?.unwatch(); 58 | this.tail = new Tail(this.logFile); 59 | 60 | this.tail.on("error", function (error) { 61 | console.log('ERROR: ', error); 62 | }); 63 | 64 | this.tail.on("line", (line: string) => { 65 | // if (line != "") console.log(line); 66 | this.check.forEach(f => { 67 | try { 68 | f(line); 69 | } catch (e) { 70 | console.warn(e); 71 | } 72 | }); 73 | }); 74 | } 75 | 76 | private check: Array<(line: string) => void> = [ 77 | (line: string) => { 78 | const match = line.match(/VRCApplication: OnApplicationQuit/); 79 | if (match) { 80 | console.log("VRChat: Quit"); 81 | State.restart = true; 82 | } 83 | }, 84 | (line: string) => { 85 | const match = line.match(/([0-9\.\: ]*) Log - \[VRC Camera\] Took screenshot to\: (.*)/); 86 | if (match) { 87 | const DateTime = match[1].replaceAll('.', ':'); 88 | 89 | const fpath = process.platform == "win32" ? match[2] : match[2].replaceAll('C:\\', (`${compatdata_path}/438100/pfx/drive_c/`)).replaceAll('\\', '/'); 90 | 91 | const tag: Array = []; 92 | 93 | tag.push(new MediaTag("DateTimeOriginal", DateTime)); 94 | tag.push(new MediaTag("CreationTime", DateTime)); 95 | tag.push(new MediaTag("ImageDescription", `at VRChat World ${State.roomInfo.world_name}, with ${State.players.toString()}`)); 96 | tag.push(new MediaTag("Description", `at VRChat World ${State.roomInfo.world_name}, with ${State.players.toString()}`)); 97 | 98 | if (State.isVL2Enabled) { 99 | tag.push(new MediaTag("Make", "logilabo")); 100 | tag.push(new MediaTag("Model", "VirtualLens2")); 101 | if (State.focalLength != Infinity) tag.push(new MediaTag("FocalLength", State.focalLength.toFixed(1))); 102 | if (State.apertureValue != config.apertureMin) tag.push(new MediaTag("FNumber", State.apertureValue.toFixed(1))); 103 | tag.push(new MediaTag("ExposureIndex", State.exposureIndex.toFixed(1))); 104 | } 105 | 106 | const makerNote = new MakerNotes(State.roomInfo, State.players); 107 | 108 | this.convertImage(fpath).then((file) => { 109 | this.writeMetadata(file, tag, makerNote).then(() => { 110 | const dir = file.split(path.sep); 111 | const targetDir = config.destDir === "" ? path.dirname(file) + "/" : config.destDir + "/" + dir[dir.length - 2] + "/"; 112 | if (!fs.existsSync(targetDir)) 113 | fs.mkdirSync(targetDir); 114 | const dest = targetDir + path.basename(file); 115 | 116 | if (path.normalize(file) != path.normalize(dest)) 117 | fs.copyFile(file, dest, fs.constants.COPYFILE_EXCL, (err) => { 118 | if (err) throw err; 119 | fs.rm(file, (err) => { if (err) throw err; }); 120 | }); 121 | }); 122 | }).catch(e => { 123 | console.warn(e); 124 | this.writeMetadata(fpath, tag, makerNote); 125 | }) 126 | // console.log(line, match); 127 | } 128 | }, 129 | (line: string) => { 130 | const match = line.match(/.*\[Behaviour\] Joining (wrld_.*?):(?:.*?(private|friends|hidden|group)\((.*?)\))?(~canRequestInvite)?/); 131 | if (match) { 132 | State.roomInfo = new RoomInfo(); 133 | State.roomInfo.world_id = match[1]; 134 | State.roomInfo.permission = (match[2] ? match[2] : "public") + (match[4] ? "+" : ""); 135 | State.roomInfo.organizer = match[3]; 136 | State.players = []; 137 | State.focalLength = config.focalDefault; 138 | State.apertureValue = config.apertureDefault; 139 | State.exposureIndex = config.exposureDefault; 140 | State.isVL2Enabled = false; 141 | 142 | // console.log(State.roomInfo); 143 | // console.log(line, match); 144 | } 145 | }, 146 | (line: string) => { 147 | const match = line.match(/Joining or Creating Room: (.*)/); 148 | if (match) { 149 | State.roomInfo.world_name = match[1]; 150 | console.log(State.roomInfo); 151 | // console.log(line, match); 152 | } 153 | }, 154 | (line: string) => { 155 | const match = line.match(/OnPlayerJoined (.*)/); 156 | if (match) { 157 | State.players.push(match[1]); 158 | // console.log(State.players.toString()); 159 | console.log("join", match[1]); 160 | // console.log(line, match); 161 | } 162 | }, 163 | (line: string) => { 164 | const match = line.match(/OnPlayerLeft (.*)/); 165 | if (match) { 166 | const i = State.players.indexOf(match[1]); 167 | if (i !== -1) { 168 | State.players.splice(i, 1); 169 | // console.log(State.players.toString()); 170 | console.log("quit", match[1]); 171 | // console.log(line, match); 172 | } 173 | } 174 | } 175 | ]; 176 | 177 | private async writeMetadata(file: string, data: MediaTag[], makerNotes?: MakerNotes): Promise { 178 | return new Promise((res, rej) => { 179 | const argFile = path.dirname(file) + path.sep + path.basename(file) + ".tags.txt"; 180 | console.log(argFile); 181 | const args = fs.createWriteStream(argFile); 182 | 183 | args.write("-overwrite_original\n"); 184 | data.forEach(e => { 185 | args.write(e.toString() + "\n"); 186 | }); 187 | args.write(`-makernote=${Buffer.from(JSON.stringify(makerNotes)).toString('base64')}\n`); 188 | 189 | args.close((e) => { 190 | if (e) { 191 | console.warn(e); 192 | rej(e); 193 | } 194 | 195 | exiftool.write(file, {}, ["-@", argFile]) 196 | .then(() => { 197 | res(); 198 | fs.rmSync(argFile); 199 | }) 200 | .catch((e) => { 201 | console.warn(e); 202 | rej(); 203 | }); 204 | }); 205 | }); 206 | } 207 | private async convertImage(file: string): Promise { 208 | return new Promise((res, rej) => { 209 | if (config.compressFormat === "") { res(file); return; } 210 | const dest = file.replace(/.png$/, '.' + config.compressFormat); 211 | sharp(file) 212 | .toFormat(config.compressFormat, config.compressOptions) 213 | .toFile(dest) 214 | .then(() => { 215 | fs.rm(file, (e) => { 216 | if (e) rej(e); 217 | else res(dest); 218 | }) 219 | }) 220 | .catch((e: any) => rej(e)); 221 | }); 222 | } 223 | } 224 | 225 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2021", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 22 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | 26 | /* Modules */ 27 | "module": "commonjs", /* Specify what module code is generated. */ 28 | "rootDir": "./src", /* Specify the root folder within your source files. */ 29 | "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 30 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 31 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 32 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 33 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 34 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 35 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 36 | // "resolveJsonModule": true, /* Enable importing .json files */ 37 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 38 | 39 | /* JavaScript Support */ 40 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 41 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 42 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 43 | 44 | /* Emit */ 45 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 46 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 47 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 48 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 49 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ 50 | "outDir": "./built", /* Specify an output folder for all emitted files. */ 51 | // "removeComments": true, /* Disable emitting comments. */ 52 | // "noEmit": true, /* Disable emitting files from a compilation. */ 53 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 54 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 55 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 56 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 59 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 60 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 61 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 62 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 63 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 64 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 65 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 66 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 67 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 68 | 69 | /* Interop Constraints */ 70 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 71 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 72 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */ 73 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 74 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 75 | 76 | /* Type Checking */ 77 | "strict": true, /* Enable all strict type-checking options. */ 78 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 79 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 80 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 81 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 82 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 83 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 84 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 85 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 86 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 87 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ 88 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 89 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 90 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 91 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 92 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 93 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 94 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 95 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 96 | 97 | /* Completeness */ 98 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 99 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 100 | } 101 | } 102 | --------------------------------------------------------------------------------