Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions plugins/VR180TB/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# VR 180 TB

I have lots of VR scenes that are 180-degree where the left and right eye images are vertical and they don't play properly in any of the current selections (180 LR, 360 mono, and 360 TB) in Stash.

This plugin adds a **180 TB** projection option to Stash's existing VR selector for 180-degree stereoscopic videos whose left and right eye images are stacked vertically.

Stash currently exposes an **180 LR** option, but the bundled `videojs-vr` implementation does not provide a native `180_TB` projection. This plugin reuses the existing 180-degree stereo geometry and remaps its texture coordinates for top/bottom stereo.

## Features

- Adds **180 TB** directly after Stash's built-in **180 LR** menu item.
- Uses Stash's native VR menu item class, so the new option matches the existing UI.
- Leaves built-in **180 LR**, **360 TB**, **360 Mono**, and **Off** behavior unchanged.
- Does not replace Video.js, Three.js, or Stash's player.
- Does not run a permanent polling interval.

## Installation

Install the plugin through the CommunityScripts source once available.

For manual installation, copy this directory into your Stash plugins directory and reload plugins:

```text
VR180TB/
├── README.md
├── VR180TB.js
└── VR180TB.yml
```

Hard-refresh the browser after installing or updating the plugin.

## Usage

Open a scene with a 180-degree top/bottom stereoscopic video and choose:

**VR selector → 180 TB**

The plugin first asks Stash/videojs-vr to create its normal **180 LR** hemisphere geometry, then remaps the two eye meshes:

| Eye | Native 180 LR UVs | 180 TB UVs |
| --- | --- | --- |
| Left | U `0..0.5`, V `0..1` | U `0..1`, V `0.5..1` |
| Right | U `0.5..1`, V `0..1` | U `0..1`, V `0..0.5` |

## Implementation notes

The current Stash VR menu is built on `@blaineam/videojs-vr`. Although older upstream changelog text referenced `180_TB`, the currently bundled projection list does not expose it.

Two implementation details are intentionally preserved:

1. `player.vr()` is initialized before `player.vrMenu()`.
2. The plugin uses Stash's native **180 LR** geometry as the base before remapping UVs.

The UV conversion is defensive. It only modifies geometry when the current UV ranges exactly match the expected native **180 LR** layout. If Stash or `videojs-vr` changes those internals in the future, the plugin leaves unexpected geometry untouched.

## Compatibility and limitations

This plugin depends on runtime details of Stash's current VR menu and `videojs-vr` integration, including the existing `VRMenuItem`, `loadVR()`, and eye-mesh geometry. A future Stash or `videojs-vr` update may require changes.

Validated with real **1920×3840 180-degree top/bottom stereoscopic video** in Chrome.

## Development

This contribution was LLM-assisted. The 180 TB behavior was developed interactively and human-tested against real media in Stash. The plugin code has been reviewed by me, the testing has been carried out by me with real VR videos on v0.31.1 of Stash. I take full responsibility for the plugin code and its license compliance.

## License

This plugin is intended for inclusion in the Stash CommunityScripts repository and is licensed under the repository's GNU Affero General Public License v3.
296 changes: 296 additions & 0 deletions plugins/VR180TB/VR180TB.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
// SPDX-License-Identifier: AGPL-3.0-only

(() => {
"use strict";

const PluginApi = window.PluginApi;

if (!PluginApi?.Event) {
return;
}

const PLAYER_ID = "VideoJsPlayer";
const LR_TYPE = "180 LR";
const TB_TYPE = "180 TB";

const EPSILON = 0.0001;
const PLAYER_WAIT_MS = 10000;
const REMAP_DELAYS_MS = [0, 16, 50, 150, 400];

function nearlyEqual(a, b) {
return Math.abs(a - b) <= EPSILON;
}

function getUVBounds(uv) {
let minU = Infinity;
let maxU = -Infinity;
let minV = Infinity;
let maxV = -Infinity;

for (let i = 0; i < uv.count; i++) {
const u = uv.getX(i);
const v = uv.getY(i);

minU = Math.min(minU, u);
maxU = Math.max(maxU, u);
minV = Math.min(minV, v);
maxV = Math.max(maxV, v);
}

return { minU, maxU, minV, maxV };
}

function isLRLeft(bounds) {
return (
nearlyEqual(bounds.minU, 0) &&
nearlyEqual(bounds.maxU, 0.5) &&
nearlyEqual(bounds.minV, 0) &&
nearlyEqual(bounds.maxV, 1)
);
}

function isLRRight(bounds) {
return (
nearlyEqual(bounds.minU, 0.5) &&
nearlyEqual(bounds.maxU, 1) &&
nearlyEqual(bounds.minV, 0) &&
nearlyEqual(bounds.maxV, 1)
);
}

function isTBLeft(bounds) {
return (
nearlyEqual(bounds.minU, 0) &&
nearlyEqual(bounds.maxU, 1) &&
nearlyEqual(bounds.minV, 0.5) &&
nearlyEqual(bounds.maxV, 1)
);
}

function isTBRight(bounds) {
return (
nearlyEqual(bounds.minU, 0) &&
nearlyEqual(bounds.maxU, 1) &&
nearlyEqual(bounds.minV, 0) &&
nearlyEqual(bounds.maxV, 0.5)
);
}

function getEyeMeshes(vr) {
if (vr?.movieScreenLeft?.geometry && vr?.movieScreenRight?.geometry) {
return {
left: vr.movieScreenLeft,
right: vr.movieScreenRight,
};
}

const meshes = vr?.scene?.children?.filter(
(object) => object?.isMesh && object?.geometry?.attributes?.uv
);

if (!meshes || meshes.length !== 2) {
return null;
}

// videojs-vr uses layer 1 (mask 2) for the left eye and
// layer 2 (mask 4) for the right eye.
const left = meshes.find((mesh) => mesh.layers?.mask === 2) ?? meshes[0];
const right = meshes.find((mesh) => mesh.layers?.mask === 4) ?? meshes[1];

return { left, right };
}

function remap180LRtoTB(vr) {
const eyes = getEyeMeshes(vr);

if (!eyes) {
return false;
}

const leftUV = eyes.left.geometry?.attributes?.uv;
const rightUV = eyes.right.geometry?.attributes?.uv;

if (!leftUV || !rightUV) {
return false;
}

const leftBounds = getUVBounds(leftUV);
const rightBounds = getUVBounds(rightUV);

// Already converted. Keeping this idempotent makes delayed retries safe.
if (isTBLeft(leftBounds) && isTBRight(rightBounds)) {
return true;
}

// Only modify the exact 180 LR layout Stash/videojs-vr currently creates.
// If those internals change, leave the geometry untouched rather than
// applying a potentially destructive transform.
if (!isLRLeft(leftBounds) || !isLRRight(rightBounds)) {
return false;
}

// Left eye:
// LR source U 0..0.5, V 0..1
// TB source U 0..1, V 0.5..1
for (let i = 0; i < leftUV.count; i++) {
const u = leftUV.getX(i);
const v = leftUV.getY(i);

leftUV.setXY(i, u * 2, 0.5 + v * 0.5);
}
leftUV.needsUpdate = true;

// Right eye:
// LR source U 0.5..1, V 0..1
// TB source U 0..1, V 0..0.5
for (let i = 0; i < rightUV.count; i++) {
const u = rightUV.getX(i);
const v = rightUV.getY(i);

rightUV.setXY(i, (u - 0.5) * 2, v * 0.5);
}
rightUV.needsUpdate = true;

return true;
}

function scheduleTBRemap(player, vrMenu) {
const vr = player?.vr?.();

if (!vr) {
return;
}

for (const delay of REMAP_DELAYS_MS) {
window.setTimeout(() => {
// Do not let a delayed retry modify a projection the user selected
// after leaving 180 TB.
if (vrMenu?.menu?.selectedType !== TB_TYPE) {
return;
}

remap180LRtoTB(vr);
}, delay);
}
}

function addTBMenuItem(vrMenu) {
const menu = vrMenu?.menu;

if (!menu?.items?.length || typeof menu.update !== "function") {
return false;
}

if (menu.items.some((item) => item?.type === TB_TYPE)) {
return true;
}

const ExistingMenuItem = menu.items[0]?.constructor;

if (
typeof ExistingMenuItem !== "function" ||
typeof menu.onSelected !== "function"
) {
return false;
}

// Reuse Stash's own VRMenuItem implementation so 180 TB looks and behaves
// like the built-in projection choices.
const tbItem = new ExistingMenuItem(menu, TB_TYPE);

tbItem.on("selected", () => {
menu.onSelected(tbItem);
});

const lrIndex = menu.items.findIndex((item) => item?.type === LR_TYPE);
const insertAt = lrIndex >= 0 ? lrIndex + 1 : 1;

menu.items.splice(insertAt, 0, tbItem);
menu.update();

return true;
}

function patchPlayer(player) {
if (!player || typeof player.vr !== "function" || typeof player.vrMenu !== "function") {
return false;
}

/*
* Initialization order matters here.
*
* Initializing videojs-vr before Stash's vrMenu wrapper is the order that
* produces correct 180 TB playback with the current Stash/videojs-vr
* combination. Do not reorder these calls without retesting.
*/
player.vr();
const vrMenu = player.vrMenu();

if (!vrMenu) {
return false;
}

if (!vrMenu.__vr180tbPatched) {
if (typeof vrMenu.loadVR !== "function") {
return false;
}

const originalLoadVR = vrMenu.loadVR.bind(vrMenu);

vrMenu.loadVR = function vr180tbLoadVR(type) {
if (type !== TB_TYPE) {
return originalLoadVR(type);
}

// Build the native 180 LR hemisphere pair, then repack its UVs as TB.
const result = originalLoadVR(LR_TYPE);
scheduleTBRemap(player, vrMenu);

return result;
};

vrMenu.__vr180tbPatched = true;
}

return addTBMenuItem(vrMenu);
}

function getPlayer() {
return document.getElementById(PLAYER_ID)?.player ?? null;
}

function patchScenePlayerWhenReady() {
const deadline = performance.now() + PLAYER_WAIT_MS;

const attempt = () => {
const player = getPlayer();

if (player && patchPlayer(player)) {
return;
}

if (performance.now() < deadline) {
window.requestAnimationFrame(attempt);
}
};

attempt();
}

function isScenePath(pathname) {
return /^\/scenes\/\d+(?:\/|$)/.test(pathname);
}

PluginApi.Event.addEventListener("stash:location", (event) => {
const pathname = event?.detail?.data?.location?.pathname;

if (pathname && isScenePath(pathname)) {
patchScenePlayerWhenReady();
}
});

// The plugin may load after the initial navigation event has already fired.
if (isScenePath(window.location.pathname)) {
patchScenePlayerWhenReady();
}
})();
7 changes: 7 additions & 0 deletions plugins/VR180TB/VR180TB.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
name: VR 180 TB
description: Adds 180-degree top/bottom stereoscopic playback to Stash's VR projection selector.
version: 1.0.0

ui:
javascript:
- VR180TB.js