@natsuneko-laboratory/memora 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kanon Mochizuki
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.
package/README.md ADDED
@@ -0,0 +1,193 @@
1
+ # Memora
2
+
3
+ Memora は、VRChat・VRCX・ResoniteScreenshotExtensions が画像に埋め込んだメタデータを読み取る TypeScript ライブラリです。撮影者、ワールド、カメラ設定、参加者の位置・姿勢などを、形式ごとの型付きオブジェクトとして取得できます。
4
+
5
+ Node.js、ブラウザー、React Native 向けに、`ArrayBuffer` または `Uint8Array` を受け取る API を提供します。ファイルの読み取りやネットワークアクセスは呼び出し側で行います。
6
+
7
+ ## インストール
8
+
9
+ ```sh
10
+ $ pnpm install @natsuneko-laboratory/memora
11
+ ```
12
+
13
+ ## クイックスタート
14
+
15
+ ```ts
16
+ import { parseImageMetadata } from "@natsuneko-laboratory/memora";
17
+
18
+ // bytes: ArrayBuffer | Uint8Array
19
+ const metadata = await parseImageMetadata(bytes);
20
+
21
+ if (metadata) {
22
+ switch (metadata.type) {
23
+ case "VRChat":
24
+ console.log(metadata.author, metadata.worldDisplayName, metadata.createDate);
25
+ break;
26
+
27
+ case "VRCX":
28
+ console.log(metadata.author?.displayName, metadata.world?.instanceId);
29
+ console.log(metadata.players);
30
+ break;
31
+
32
+ case "ResoniteScreenshotExtensions":
33
+ console.log(metadata.takenBy?.name, metadata.cameraFOV);
34
+ console.log(metadata.userInfos);
35
+ break;
36
+ }
37
+ }
38
+ ```
39
+
40
+ `type` で分岐すると、その形式のプロパティへ型安全にアクセスできます。メタデータがない画像や、対応形式を認識できない入力では `null` を返します。
41
+
42
+ ## API
43
+
44
+ ### `parseImageMetadata(input)`
45
+
46
+ ```ts
47
+ function parseImageMetadata(input: ArrayBuffer | Uint8Array): Promise<ImageMetadata | null>;
48
+ ```
49
+
50
+ 画像から 1 件のメタデータを取得します。複数形式が同居する場合は ResoniteScreenshotExtensions を優先し、それ以外は解析結果の先頭を返します。
51
+
52
+ `parsePhotoMetadata` はこの関数の別名です。
53
+
54
+ ### `parseAllImageMetadata(input)`
55
+
56
+ ```ts
57
+ function parseAllImageMetadata(input: ArrayBuffer | Uint8Array): Promise<ImageMetadata[]>;
58
+ ```
59
+
60
+ 画像内で認識できたすべてのメタデータを取得します。複数形式や複数パケットを扱う場合に使用してください。認識できるメタデータがなければ空配列を返します。
61
+
62
+ ### `ImageMetadata`
63
+
64
+ ```ts
65
+ type ImageMetadata =
66
+ VrcxImageMetadata | VRChatImageMetadata | ResoniteScreenshotExtensionsImageMetadata;
67
+ ```
68
+
69
+ すべての形式に、次のプロパティがあります。
70
+
71
+ | プロパティ | 型 | 内容 |
72
+ | ---------- | ------------------------------------------------------ | ------------------------------------------------------ |
73
+ | `type` | `"VRCX" \| "VRChat" \| "ResoniteScreenshotExtensions"` | メタデータの形式 |
74
+ | `raw` | `Uint8Array` | 元画像全体のバイト列 |
75
+ | `extra` | `Record<string, unknown>` | 型定義にない項目、または期待する型へ解析できなかった値 |
76
+
77
+ 画像に含まれない項目は optional プロパティとして扱います。空文字が埋め込まれている場合は、その空文字を保持します。
78
+
79
+ ## 対応形式とフィールド
80
+
81
+ ### VRChat
82
+
83
+ `type: "VRChat"`
84
+
85
+ - `creatorTool`, `author`, `authorId`
86
+ - `worldId`, `worldDisplayName`
87
+ - `createDate`, `modifyDate`, `dateTime`
88
+ - `title`: 言語ごとの `{ value: string; language?: string }[]`
89
+ - `world`: 旧形式の `vrc:World`
90
+
91
+ 旧形式の `author` にはユーザー ID が入る場合があります。新しい形式では表示名と `authorId` を別々に取得できます。
92
+
93
+ ### VRCX
94
+
95
+ `type: "VRCX"`
96
+
97
+ - `application`, `version`
98
+ - `author`: `id`, `displayName`
99
+ - `world`: `id`, `name`, `instanceId`
100
+ - `players`: `id`, `displayName` を持つユーザーの配列
101
+
102
+ ユーザーとワールドには、それぞれ `extra` もあります。
103
+
104
+ ### ResoniteScreenshotExtensions
105
+
106
+ `type: "ResoniteScreenshotExtensions"`
107
+
108
+ - 場所: `locationName`, `locationAccessLevel`, `locationHiddenFromListing`, `locationHost`
109
+ - 撮影: `timeTaken`, `takenBy`, `takenGlobalPosition`, `takenGlobalRotation`, `takenGlobalScale`
110
+ - アプリ・カメラ: `appVersion`, `cameraManufacturer`, `cameraModel`, `cameraFOV`, `is360`, `stereoLayout`
111
+ - 参加者: `userInfos`
112
+
113
+ `locationHost` と `takenBy` は `id`, `name`, `machineId`, `extra` を持ちます。`userInfos` の各要素には、さらに次のフィールドがあります。
114
+
115
+ - `isInVR`, `isPresent`
116
+ - `headPosition`, `headOrientation`
117
+ - `sessionJoinTimestamp`
118
+
119
+ `userInfos` は、ユーザーが 1 人の場合も配列です。
120
+
121
+ ## 値の扱い
122
+
123
+ - 日時は元の文字列を保持します。タイムゾーン変換や `Date` 化を行わず、小数秒の精度も維持します。
124
+ - Resonite の名前は Unicode / URI エスケープを復元します。
125
+ - 真偽値は `boolean`、カメラの画角は `number` に解析します。
126
+ - 位置・スケールは `Vector3`(`[number, number, number]`)、回転は `Quaternion`(`[number, number, number, number]`)に解析します。座標変換や正規化は行いません。
127
+ - 未知の項目や解析できない値は `extra` に保持し、既定値で補完しません。
128
+
129
+ XML の属性や名前空間の構造は、形式ごとのプロパティへ整理されます。構文表記まで含めた元データが必要な場合は `raw` を利用してください。JSON 数値の精度は JavaScript の `number` に従います。
130
+
131
+ ## 実行環境ごとの利用例
132
+
133
+ ### Node.js
134
+
135
+ ```ts
136
+ import { readFile } from "node:fs/promises";
137
+ import { parseImageMetadata } from "@natsuneko-laboratory/memora";
138
+
139
+ const bytes = await readFile("screenshot.png");
140
+ const metadata = await parseImageMetadata(bytes);
141
+ ```
142
+
143
+ ### ブラウザー
144
+
145
+ ```ts
146
+ import { parseImageMetadata } from "@natsuneko-laboratory/memora";
147
+
148
+ async function readScreenshot(file: File | Blob) {
149
+ return parseImageMetadata(await file.arrayBuffer());
150
+ }
151
+ ```
152
+
153
+ ### React Native
154
+
155
+ ```ts
156
+ import { parseImageMetadata } from "@natsuneko-laboratory/memora";
157
+
158
+ async function readScreenshot(uri: string, readBytes: (uri: string) => Promise<Uint8Array>) {
159
+ const bytes = await readBytes(uri);
160
+ return parseImageMetadata(bytes);
161
+ }
162
+ ```
163
+
164
+ `readBytes` には、アプリで採用しているファイルアクセスライブラリを使った読み取り関数を渡してください。URI、Base64、`File`、`Blob` をパーサーへ直接渡すことはできません。
165
+
166
+ ## 対応範囲と制約
167
+
168
+ PNG の iTXt(非圧縮・zlib 圧縮)と XMP を読み取ります。ResoniteScreenshotExtensions の JPEG XMP にも対応します。
169
+
170
+ 画像のリサイズ・再圧縮・形式変換により、メタデータが削除される場合があります。変換前の元画像を使用してください。
171
+
172
+ コアは React やネイティブモジュールに依存しません。Node.js の `Buffer` / `process`、DOM、`TextDecoder` がない環境でのバンドルテストを用意しています。
173
+
174
+ ## 開発
175
+
176
+ リポジトリを取得した後、パッケージのディレクトリで実行します。
177
+
178
+ ```sh
179
+ npm install
180
+ npm test
181
+ npm run typecheck
182
+ npm run build
183
+ ```
184
+
185
+ テストは TypeScript と Vitest で記述しています。実画像と合成画像を用い、形式の判別、全フィールドの取得、元バイト列の保持、部分バッファ入力、実行環境への依存を検証します。
186
+
187
+ `npm run typecheck` は本体とテストの両方を検証します。テストのみの型チェックには `npm run typecheck:test` を使用できます。
188
+
189
+ ビルドすると、ESM と型定義が `dist/` に生成されます。インストール時のスクリプトを無効にしている場合は、利用前に `npm run build` を実行してください。
190
+
191
+ ## ライセンス
192
+
193
+ MIT
@@ -0,0 +1,31 @@
1
+ export type PhotoInput = ArrayBuffer | Uint8Array;
2
+ export type XmpMetadata = {
3
+ /** Original XML text, including whitespace and entity spellings. */
4
+ text: string;
5
+ /** All XML fields and attributes; no scalar coercion or namespace removal. */
6
+ data: Record<string, unknown> | null;
7
+ };
8
+ export type PhotoTextChunk = {
9
+ keyword: string;
10
+ compressionFlag: number;
11
+ compressionMethod: number;
12
+ languageTag: string;
13
+ translatedKeyword: string;
14
+ /** Decompressed text, or null if the payload cannot be decoded. */
15
+ text: string | null;
16
+ /** Complete JSON value for Description chunks, otherwise null. */
17
+ data: unknown;
18
+ /** Original iTXt payload, including its header and compressed bytes. */
19
+ raw: Uint8Array;
20
+ };
21
+ export type PhotoMetadata = {
22
+ /** Original image bytes, for lossless access beyond the decoded views. */
23
+ raw: Uint8Array;
24
+ /** All blocks returned by exifr, with numeric tag IDs and value translation disabled. */
25
+ exif: Record<string, unknown> | null;
26
+ /** All PNG iTXt chunks in file order, including unknown keywords and versions. */
27
+ itxt: PhotoTextChunk[];
28
+ xmp: XmpMetadata[];
29
+ };
30
+ /** Return embedded metadata without platform filtering, renaming, defaults or value normalization. */
31
+ export declare const readMetadataContainer: (input: PhotoInput) => Promise<PhotoMetadata | null>;
@@ -0,0 +1,115 @@
1
+ import exifr from "exifr";
2
+ import { XMLParser } from "fast-xml-parser";
3
+ import { strFromU8, unzlibSync } from "fflate";
4
+ import extract from "png-chunks-extract";
5
+ const parseXmp = (text) => {
6
+ try {
7
+ const data = new XMLParser({
8
+ ignoreAttributes: false,
9
+ parseTagValue: false,
10
+ parseAttributeValue: false,
11
+ trimValues: false,
12
+ processEntities: true,
13
+ ignoreDeclaration: false,
14
+ ignorePiTags: false,
15
+ }).parse(text);
16
+ return { text, data };
17
+ }
18
+ catch {
19
+ return { text, data: null };
20
+ }
21
+ };
22
+ const readTextChunks = (bytes) => {
23
+ try {
24
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
25
+ for (let offset = 8; offset < bytes.length;) {
26
+ if (bytes.length - offset < 12)
27
+ return [];
28
+ const length = view.getUint32(offset);
29
+ if (length > bytes.length - offset - 12)
30
+ return [];
31
+ offset += length + 12;
32
+ }
33
+ return extract(bytes)
34
+ .filter((chunk) => chunk.name === "iTXt")
35
+ .map(({ data: raw }) => {
36
+ const result = {
37
+ keyword: "",
38
+ compressionFlag: 0,
39
+ compressionMethod: 0,
40
+ languageTag: "",
41
+ translatedKeyword: "",
42
+ text: null,
43
+ data: null,
44
+ raw,
45
+ };
46
+ try {
47
+ const end = raw.indexOf(0);
48
+ if (end < 1 || end + 5 > raw.length)
49
+ return result;
50
+ result.keyword = strFromU8(raw.subarray(0, end), true);
51
+ result.compressionFlag = raw[end + 1];
52
+ result.compressionMethod = raw[end + 2];
53
+ const languageEnd = raw.indexOf(0, end + 3);
54
+ if (languageEnd < 0)
55
+ return result;
56
+ result.languageTag = strFromU8(raw.subarray(end + 3, languageEnd), true);
57
+ const translatedEnd = raw.indexOf(0, languageEnd + 1);
58
+ if (translatedEnd < 0)
59
+ return result;
60
+ result.translatedKeyword = strFromU8(raw.subarray(languageEnd + 1, translatedEnd));
61
+ const payload = raw.subarray(translatedEnd + 1);
62
+ if (result.compressionMethod !== 0 || result.compressionFlag > 1)
63
+ return result;
64
+ result.text = strFromU8(result.compressionFlag === 1 ? unzlibSync(payload) : payload);
65
+ if (result.keyword === "Description")
66
+ result.data = JSON.parse(result.text);
67
+ }
68
+ catch {
69
+ // Keep the original bytes and any readable fields even if decoding fails.
70
+ }
71
+ return result;
72
+ });
73
+ }
74
+ catch {
75
+ return [];
76
+ }
77
+ };
78
+ /** Return embedded metadata without platform filtering, renaming, defaults or value normalization. */
79
+ export const readMetadataContainer = async (input) => {
80
+ const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
81
+ const itxt = readTextChunks(bytes);
82
+ let exif = null;
83
+ try {
84
+ exif =
85
+ (await exifr.parse(bytes, {
86
+ tiff: true,
87
+ ifd1: true,
88
+ interop: true,
89
+ makerNote: true,
90
+ userComment: true,
91
+ multiSegment: true,
92
+ exif: true,
93
+ gps: true,
94
+ iptc: true,
95
+ icc: true,
96
+ jfif: true,
97
+ xmp: { parse: false },
98
+ translateKeys: false,
99
+ translateValues: false,
100
+ reviveValues: false,
101
+ sanitize: false,
102
+ mergeOutput: false,
103
+ })) ?? null;
104
+ }
105
+ catch {
106
+ // iTXt remains available when another metadata block is unsupported or damaged.
107
+ }
108
+ const xmp = itxt
109
+ .filter((chunk) => chunk.keyword === "XML:com.adobe.xmp" && chunk.text !== null)
110
+ .map((chunk) => parseXmp(chunk.text));
111
+ // PNG XMP is already captured above, including duplicate packets and original headers.
112
+ if (xmp.length === 0 && typeof exif?.xmp === "string")
113
+ xmp.push(parseXmp(exif.xmp));
114
+ return exif || itxt.length ? { raw: bytes, exif, itxt, xmp } : null;
115
+ };
@@ -0,0 +1,8 @@
1
+ import type { ImageMetadata, PhotoInput } from "./types.js";
2
+ export type * from "./types.js";
3
+ /** Read every recognized packet without dropping coexisting formats. Unknown formats return an empty array. */
4
+ export declare const parseAllImageMetadata: (input: PhotoInput) => Promise<ImageMetadata[]>;
5
+ /** Resonite takes precedence, matching steambird; use parseAllImageMetadata for mixed images. */
6
+ export declare const parseImageMetadata: (input: PhotoInput) => Promise<ImageMetadata | null>;
7
+ /** Alias for existing callers. */
8
+ export declare const parsePhotoMetadata: typeof parseImageMetadata;
package/dist/index.js ADDED
@@ -0,0 +1,224 @@
1
+ import { readMetadataContainer } from "./container.js";
2
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
3
+ ? value
4
+ : undefined;
5
+ const list = (value) => value === undefined ? [] : Array.isArray(value) ? value : [value];
6
+ const string = (value) => typeof value === "string" ? value : undefined;
7
+ const number = (value) => {
8
+ if (typeof value !== "number" && (typeof value !== "string" || !value.trim()))
9
+ return undefined;
10
+ const result = Number(value);
11
+ return Number.isFinite(result) ? result : undefined;
12
+ };
13
+ const boolean = (value) => value === true || value === "true"
14
+ ? true
15
+ : value === false || value === "false"
16
+ ? false
17
+ : undefined;
18
+ const vector = (value) => {
19
+ if (typeof value !== "string" || !/^\[[^\[\]]+\]$/.test(value))
20
+ return undefined;
21
+ const parts = value.slice(1, -1).split(";").map(number);
22
+ return parts.every((part) => part !== undefined) ? parts : undefined;
23
+ };
24
+ const vector3 = (value) => {
25
+ const v = vector(value);
26
+ return v?.length === 3 ? [v[0], v[1], v[2]] : undefined;
27
+ };
28
+ const quaternion = (value) => {
29
+ const v = vector(value);
30
+ return v?.length === 4 ? [v[0], v[1], v[2], v[3]] : undefined;
31
+ };
32
+ const name = (value) => {
33
+ if (typeof value !== "string")
34
+ return undefined;
35
+ let result = value;
36
+ // ResoniteScreenshotExtensions escapes names as JSON string contents and URI text.
37
+ try {
38
+ result = JSON.parse(`"${result}"`);
39
+ }
40
+ catch {
41
+ /* Already readable text. */
42
+ }
43
+ try {
44
+ result = decodeURIComponent(result);
45
+ }
46
+ catch {
47
+ /* Literal percent sign. */
48
+ }
49
+ return result;
50
+ };
51
+ // Recognized fields that cannot be decoded remain in extra instead of disappearing.
52
+ const fields = (source) => {
53
+ const extra = { ...source };
54
+ const get = (key, decode) => {
55
+ const result = decode(source[key]);
56
+ if (result !== undefined)
57
+ delete extra[key];
58
+ return result;
59
+ };
60
+ return { extra, get };
61
+ };
62
+ const compact = (value) => {
63
+ for (const key of Object.keys(value))
64
+ if (value[key] === undefined)
65
+ delete value[key];
66
+ return value;
67
+ };
68
+ const users = (decode) => (input) => {
69
+ if (!Array.isArray(input))
70
+ return undefined;
71
+ const values = input.map(decode);
72
+ return values.every((value) => value !== undefined) ? values : undefined;
73
+ };
74
+ const vrcxUser = (input) => {
75
+ const source = object(input);
76
+ if (!source)
77
+ return undefined;
78
+ const { get, extra } = fields(source);
79
+ return compact({ id: get("id", string), displayName: get("displayName", string), extra });
80
+ };
81
+ const vrcxWorld = (input) => {
82
+ const source = object(input);
83
+ if (!source)
84
+ return undefined;
85
+ const { get, extra } = fields(source);
86
+ return compact({
87
+ id: get("id", string),
88
+ name: get("name", string),
89
+ instanceId: get("instanceId", string),
90
+ extra,
91
+ });
92
+ };
93
+ /** Flatten XML attribute syntax only; keep namespace prefixes to avoid name collisions. */
94
+ const xmlFields = (input) => Object.fromEntries(Object.entries(object(input) ?? {})
95
+ .filter(([key]) => key !== "#text" && !key.startsWith("@_xmlns") && key !== "@_rdf:about")
96
+ .map(([key, value]) => [key.startsWith("@_") ? key.slice(2) : key, value]));
97
+ const scalar = (value) => string(value) ?? string(object(value)?.["#text"]);
98
+ const resoniteUser = (input) => {
99
+ if (!object(input))
100
+ return undefined;
101
+ const { get, extra } = fields(xmlFields(input));
102
+ return compact({
103
+ id: get("rse:U-Id", scalar),
104
+ name: get("rse:U-Name", name),
105
+ machineId: get("rse:U-MachineId", scalar),
106
+ extra,
107
+ });
108
+ };
109
+ const resoniteUserInfo = (input) => {
110
+ const user = resoniteUser(input);
111
+ if (!user)
112
+ return undefined;
113
+ const { get, extra } = fields(user.extra);
114
+ return compact({
115
+ ...user,
116
+ isInVR: get("rse:UI-IsInVR", boolean),
117
+ isPresent: get("rse:UI-IsPresent", boolean),
118
+ headPosition: get("rse:UI-HeadPosition", vector3),
119
+ headOrientation: get("rse:UI-HeadOrientation", quaternion),
120
+ sessionJoinTimestamp: get("rse:UI-SessionJoinTimestamp", scalar),
121
+ extra,
122
+ });
123
+ };
124
+ const title = (input) => {
125
+ if (typeof input === "string")
126
+ return [{ value: input }];
127
+ const entries = object(object(input)?.["rdf:Alt"])?.["rdf:li"];
128
+ if (entries === undefined)
129
+ return undefined;
130
+ return list(entries).map((item) => compact({ value: scalar(item) ?? "", language: string(object(item)?.["@_xml:lang"]) }));
131
+ };
132
+ /** Read every recognized packet without dropping coexisting formats. Unknown formats return an empty array. */
133
+ export const parseAllImageMetadata = async (input) => {
134
+ const container = await readMetadataContainer(input);
135
+ if (!container)
136
+ return [];
137
+ const raw = container.raw;
138
+ const result = [];
139
+ for (const chunk of container.itxt) {
140
+ const source = object(chunk.data);
141
+ if (chunk.keyword !== "Description" || source?.application !== "VRCX")
142
+ continue;
143
+ const { get, extra } = fields(source);
144
+ delete extra.application;
145
+ result.push(compact({
146
+ type: "VRCX",
147
+ raw,
148
+ application: "VRCX",
149
+ version: get("version", number),
150
+ author: get("author", vrcxUser),
151
+ world: get("world", vrcxWorld),
152
+ players: get("players", users(vrcxUser)),
153
+ extra,
154
+ }));
155
+ }
156
+ for (const packet of container.xmp) {
157
+ const root = object(packet.data?.["x:xmpmeta"]) ?? packet.data;
158
+ const rdf = object(root?.["rdf:RDF"]);
159
+ const descriptions = list(rdf?.["rdf:Description"]).map(xmlFields);
160
+ const source = {};
161
+ for (const description of descriptions) {
162
+ for (const [key, value] of Object.entries(description)) {
163
+ if (key in source)
164
+ source[key] = [...list(source[key]), value];
165
+ else
166
+ source[key] = value;
167
+ }
168
+ }
169
+ const { get, extra } = fields(source);
170
+ if (source["xmp:CreatorTool"] === "VRChat") {
171
+ delete extra["xmp:CreatorTool"];
172
+ result.push(compact({
173
+ type: "VRChat",
174
+ raw,
175
+ creatorTool: "VRChat",
176
+ author: get("xmp:Author", scalar),
177
+ authorId: get("vrc:AuthorID", scalar),
178
+ worldId: get("vrc:WorldID", scalar),
179
+ worldDisplayName: get("vrc:WorldDisplayName", scalar),
180
+ world: get("vrc:World", scalar),
181
+ createDate: get("xmp:CreateDate", scalar),
182
+ modifyDate: get("xmp:ModifyDate", scalar),
183
+ dateTime: get("tiff:DateTime", scalar),
184
+ title: get("dc:title", title),
185
+ extra,
186
+ }));
187
+ }
188
+ else if (source["rse:CameraManufacturer"] === "Resonite") {
189
+ delete extra["rse:CameraManufacturer"];
190
+ result.push(compact({
191
+ type: "ResoniteScreenshotExtensions",
192
+ raw,
193
+ cameraManufacturer: "Resonite",
194
+ locationName: get("rse:LocationName", name),
195
+ locationAccessLevel: get("rse:LocationAccessLevel", scalar),
196
+ locationHiddenFromListing: get("rse:LocationHiddenFromListing", boolean),
197
+ locationHost: get("rse:LocationHost", resoniteUser),
198
+ timeTaken: get("rse:TimeTaken", scalar),
199
+ takenBy: get("rse:TakenBy", resoniteUser),
200
+ takenGlobalPosition: get("rse:TakenGlobalPosition", vector3),
201
+ takenGlobalRotation: get("rse:TakenGlobalRotation", quaternion),
202
+ takenGlobalScale: get("rse:TakenGlobalScale", vector3),
203
+ appVersion: get("rse:AppVersion", scalar),
204
+ cameraModel: get("rse:CameraModel", scalar),
205
+ cameraFOV: get("rse:CameraFOV", number),
206
+ is360: get("rse:Is360", boolean),
207
+ stereoLayout: get("rse:StereoLayout", scalar),
208
+ userInfos: get("rse:UserInfos", (value) => {
209
+ const infos = object(value)?.["rse:UserInfo"];
210
+ return infos === undefined ? undefined : users(resoniteUserInfo)(list(infos));
211
+ }),
212
+ extra,
213
+ }));
214
+ }
215
+ }
216
+ return result;
217
+ };
218
+ /** Resonite takes precedence, matching steambird; use parseAllImageMetadata for mixed images. */
219
+ export const parseImageMetadata = async (input) => {
220
+ const results = await parseAllImageMetadata(input);
221
+ return (results.find((result) => result.type === "ResoniteScreenshotExtensions") ?? results[0] ?? null);
222
+ };
223
+ /** Alias for existing callers. */
224
+ export const parsePhotoMetadata = parseImageMetadata;
@@ -0,0 +1,78 @@
1
+ export type PhotoInput = ArrayBuffer | Uint8Array;
2
+ export type Vector3 = [number, number, number];
3
+ export type Quaternion = [number, number, number, number];
4
+ /** Unrecognized source fields, retained at the level where they appeared. */
5
+ export type ExtraFields = {
6
+ extra: Record<string, unknown>;
7
+ };
8
+ type ImageBase = ExtraFields & {
9
+ raw: Uint8Array;
10
+ };
11
+ export type VrcxUser = ExtraFields & {
12
+ id?: string;
13
+ displayName?: string;
14
+ };
15
+ export type VrcxWorld = ExtraFields & {
16
+ id?: string;
17
+ name?: string;
18
+ instanceId?: string;
19
+ };
20
+ export type VrcxImageMetadata = ImageBase & {
21
+ type: "VRCX";
22
+ application: "VRCX";
23
+ version?: number;
24
+ author?: VrcxUser;
25
+ world?: VrcxWorld;
26
+ players?: VrcxUser[];
27
+ };
28
+ export type VRChatImageMetadata = ImageBase & {
29
+ type: "VRChat";
30
+ creatorTool: "VRChat";
31
+ /** Original xmp:Author. Old screenshots contain the user ID here. */
32
+ author?: string;
33
+ authorId?: string;
34
+ worldId?: string;
35
+ worldDisplayName?: string;
36
+ /** Original vrc:World, used by the legacy format. */
37
+ world?: string;
38
+ createDate?: string;
39
+ modifyDate?: string;
40
+ dateTime?: string;
41
+ title?: Array<{
42
+ value: string;
43
+ language?: string;
44
+ }>;
45
+ };
46
+ export type ResoniteUser = ExtraFields & {
47
+ id?: string;
48
+ name?: string;
49
+ machineId?: string;
50
+ };
51
+ export type ResoniteUserInfo = ResoniteUser & {
52
+ isInVR?: boolean;
53
+ isPresent?: boolean;
54
+ headPosition?: Vector3;
55
+ headOrientation?: Quaternion;
56
+ sessionJoinTimestamp?: string;
57
+ };
58
+ export type ResoniteScreenshotExtensionsImageMetadata = ImageBase & {
59
+ type: "ResoniteScreenshotExtensions";
60
+ cameraManufacturer: "Resonite";
61
+ locationName?: string;
62
+ locationAccessLevel?: string;
63
+ locationHiddenFromListing?: boolean;
64
+ locationHost?: ResoniteUser;
65
+ timeTaken?: string;
66
+ takenBy?: ResoniteUser;
67
+ takenGlobalPosition?: Vector3;
68
+ takenGlobalRotation?: Quaternion;
69
+ takenGlobalScale?: Vector3;
70
+ appVersion?: string;
71
+ cameraModel?: string;
72
+ cameraFOV?: number;
73
+ is360?: boolean;
74
+ stereoLayout?: string;
75
+ userInfos?: ResoniteUserInfo[];
76
+ };
77
+ export type ImageMetadata = VrcxImageMetadata | VRChatImageMetadata | ResoniteScreenshotExtensionsImageMetadata;
78
+ export {};
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@natsuneko-laboratory/memora",
3
+ "version": "0.1.0",
4
+ "description": "Memora extracts VRChat, VRCX and ResoniteScreenshotExtensions metadata for Node.js, Browser and React Native.",
5
+ "license": "MIT",
6
+ "files": [
7
+ "dist",
8
+ "README.md"
9
+ ],
10
+ "type": "module",
11
+ "sideEffects": false,
12
+ "main": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "default": "./dist/index.js"
18
+ }
19
+ },
20
+ "dependencies": {
21
+ "exifr": "^7.1.3",
22
+ "fast-xml-parser": "^5.2.0",
23
+ "fflate": "^0.8.2",
24
+ "png-chunks-extract": "^1.0.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^26.4.1",
28
+ "@types/png-chunks-extract": "^1.0.2",
29
+ "esbuild": "^0.28.1",
30
+ "typescript": "^7.0.2",
31
+ "vitest": "^5.0.0"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "scripts": {
37
+ "build": "tsc -p tsconfig.json",
38
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json",
39
+ "test": "npm run build && vitest run",
40
+ "typecheck:test": "tsc -p tsconfig.test.json"
41
+ }
42
+ }