A gallery of cherished moments shared by Mars and his amazing friends in VRChat. vrc.maao.cc
You may feel free to create your own gallery based on this project, regardless the game. (whispers) VRChat is such a nice game that you should definitely try!
This prototype is a Vue, Vite, and TypeScript personal gallery. Metadata lives in JSON files:
src/data/images.jsonsrc/data/friends.jsonsrc/data/worlds.jsonsrc/data/special-events.jsonsrc/data/tags.json
Full-size photos are loaded from public/photos/. Thumbnails are loaded from
public/photos/thumbnails/ with the same filename.
Descriptions can reference friends by id with [[friend_id]], for example
[[eric]]. The site renders the current display name from friends.json.
Linked moments use the linked field with numeric image ids. Child photos use
parent so they stay attached to the parent gallery card instead of appearing
as standalone outings.
The captured field is treated as UTC+8 when it does not include an explicit
timezone suffix. This keeps gallery dates stable for visitors in other
timezones while matching the VRChat screenshot filename time.
URLs can deep-link into the gallery flow. Use #60 to scroll to photo id 60,
or use a special event id such as #mars-17th-birthday to scroll to that event.
Lightbox photos can show Instagram-like friend tags. Tags live in
src/data/tags.json instead of images.json:
{
"photo": 4,
"tags": [
{
"friend": "mooncake",
"x": 55.5,
"y": 58.8
},
{
"friend": "eric",
"x": 77.9,
"y": 71.9,
"position": "right"
}
]
}Tag fields:
photo: numeric image id fromimages.json.friend: friend id fromfriends.json.xandy: percentage coordinates on the displayed image, from 0 to 100.position: optional label position, one oftop,right,bottom, orleft. When omitted, the label appears below the dot.
Tags follow the lightbox chrome visibility. Clicking the photo or zooming hides the tags with the caption and controls.
While running the local dev server, the browser console exposes tag editing helpers:
tagsEdit.on()
tagsEdit.off()
tagsEdit.print()
await tagsEdit.save()Use tagsEdit.on() with a photo open in the lightbox, drag the tag dots to
their real positions, then use tagsEdit.print() to print the current photo's
tag JSON. You can move through multiple photos and adjust tags in one session;
await tagsEdit.save() writes all current in-browser tag groups back to
src/data/tags.json through the local Vite dev server.
tagsEdit.save() only works during local development with npm run dev.
Production builds cannot write source files. For a quick coordinate template,
click anywhere on the lightbox image; the app will show and copy a starter JSON
block with that click's x and y.
Special events are configured in src/data/special-events.json and are
inserted into the normal gallery flow by photo time. They render as a full-width
module matching the current gallery stream width, so the event can sit between
regular outings without becoming a separate page.
Mark every photo that belongs to a special event in images.json:
{
"id": 60,
"filename": "VRChat_2026-06-15_23-36-26.jpg",
"captured": "2026-06-15T23:36:26",
"world": "birthday.celebration",
"friend": ["persimmon"],
"special-events": true
}Those photos are removed from the ordinary gallery stream and shown by the
matching special event instead. They can still use linked and parent to keep
related photos grouped.
Example event:
{
"id": "mars-17th-birthday",
"title_en": "Mars' 17th birthday party",
"title_zh": "毛毛的 17 岁生日会",
"date_en": "June 16, 2026",
"date_zh": "2026 年 6 月 16 日",
"show_full_date": true,
"world": "birthday.celebration",
"friends": ["persimmon", "tofu"],
"description_en": "A small room, a cake, and friends showing up from VRChat.",
"description_zh": "一个小房间、一块蛋糕,还有从 VRChat 赶来的朋友。",
"photo_ids": [60, 61, 62, 63],
"featured_photo_ids": [60, 62]
}Special event fields:
photo_ids: all photos shown inside the event.featured_photo_ids: large header photos. On desktop and tablet gallery layouts they appear in two columns; on mobile they stack into one column.world: event-level world. If a photo has the same world, the photo world is hidden to avoid repeating information. If it differs, the photo keeps its own world label.friends: event-level friends. They render below the description with the sameWITHcopy and friend-name handling as the gallery.show_full_date: when true, every event photo shows date and time, such asJune 16 at 12:01 AM. When omitted or false, photos on the same gallery day collapse to time only, and cross-day photos show date and time without the year.
The event label is styled in green and event dates/times are styled in yellow. Linked photos inside an event use compact gallery-like thumbnails.
To add blank templates for new VRChat_*.png, VRChat_*.jpg, or
VRChat_*.jpeg files in public/photos/:
python3 scripts/generate_images_json.pyWith custom folders:
python3 scripts/generate_images_json.py --input public/photos --output src/data
python3 scripts/generate_tags.py
python3 scripts/batch_rename.py --input raw-photos
python3 scripts/batch_resize.py --input public/photos
python3 scripts/batch_png_to_jpg.py --input public/photosgenerate_images_json.py writes to src/data/images.json by default. The
script reads the current JSON file, skips filenames already in the file, and
appends new templates with the next available numeric ids. It accepts
VRChat_YYYY-MM-DD_HH-MM-SS.png, .jpg, and .jpeg filenames.
generate_tags.py reads images.json and tags.json, then creates tag
templates in tags.json for photos that list friends but do not yet have tags.
Existing tag groups are preserved.
batch_rename.py renames .png, .jpg, and .jpeg files in place while
preserving their file extension. batch_resize.py writes thumbnails to
INPUT/thumbnails/ unless --output is provided, and keeps JPEG output as
JPEG and PNG output as PNG. batch_png_to_jpg.py converts VRChat_*.png
files to same-name .jpg files in INPUT/ unless --output is provided, and
skips existing JPG files unless --overwrite is passed.
Preview without writing:
python3 scripts/generate_images_json.py --dry-run
python3 scripts/generate_tags.py --dry-runnpm install
npm run devFor a production build:
npm run build