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
4 changes: 4 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

# Changelog

## Unreleased

- Keep a photo's rotation when switching between the fisheye and equirectangular projections.

## v0.1.6 (2026-02-13)

- Add option to show/hide the toolbar for the sphere navigation.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ flatpak install flathub io.github.dynobo.sphereview
- Lean interface with minimal features
- 100% offline, no telemetry
- Supports Equirectanglar projection (e.g. used by Google Camera)
- Supports Dual Fisheye projection (e.g. Insta360 photos) (Experimental!)
- Supports formats like JPEG, PNG, WEBP

## Screencast
Expand Down
3 changes: 2 additions & 1 deletion build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ fn run_command(command: &str, args: &[&str], dir: &str) {

fn main() {
println!("cargo:rerun-if-changed=resources/photosphereviewer/index.html");
println!("cargo:rerun-if-changed=resources/photosphereviewer/src/viewer.js");
println!("cargo:rerun-if-changed=resources/photosphereviewer/src");
println!("cargo:rerun-if-changed=resources/photosphereviewer/src/style.css");
println!("cargo:rerun-if-changed=resources/photosphereviewer/public/demo.webp");
println!("cargo:rerun-if-changed=resources/data/window.blp");
Expand All @@ -29,6 +29,7 @@ fn main() {
run_command("npm", &["ci", "--offline"], "resources/photosphereviewer");
}

run_command("npm", &["test"], "resources/photosphereviewer");
run_command("npm", &["run", "build"], "resources/photosphereviewer");

run_command(
Expand Down
205 changes: 205 additions & 0 deletions docs/superpowers/plans/2026-11-12-rotation-across-projections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
# Rotation Across Projections Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** A quarter-turn rotation given to a dual fisheye photo survives switching the projection to equirectangular and back.

**Architecture:** The page keeps a `Map` from file key to the roll a photo was last left at, and applies it only in dual fisheye mode. The file key is a hash of the photo's path, computed in Rust and passed as the fourth argument of `setPanoramaImageFromBase64`, so a projection switch (same key) is distinguishable from a new file (new key).

**Tech Stack:** Rust (GTK4/libadwaita, `webkit6`), JavaScript (Photo Sphere Viewer 5.15, Vite single-file build).

**Spec:** `docs/superpowers/specs/2026-11-12-rotation-across-projections-design.md`

## Global Constraints

- No persistence: the rotation memory lives in the page only, nothing on disk, nothing in the photo file, nothing across restarts.
- No new tests (decided with the partner). Existing tests must keep passing: `npm test` in `resources/photosphereviewer`, `cargo test` at the repo root.
- Comments explain *why*, in the style of the surrounding code; no comment restates the code.
- The equirectangular view of a rotated photo must be level; the rotate button stays hidden there.

## Review Focus

- A rotated fisheye photo followed by an unrelated equirectangular photo: the second must open level, with no rotate button.
- Two different photos whose rotations differ: each must keep its own across projection switches.
- A projection switch while the demo image is open: no rotation, no button, no error in the console.
- A photo re-opened from the file dialog after being rotated: it comes back as it was left.
- The first load of a session (demo image, no path): the file key is stable and the loader does not throw.

---

### Task 1: The page remembers a photo's rotation

**Files:**
- Modify: `resources/photosphereviewer/src/viewer.js` (the `rotatePanorama` function and `setPanoramaImageFromBase64`)

**Interfaces:**
- Consumes: nothing from other tasks.
- Produces: `setPanoramaImageFromBase64(base64: string, mimeType: string, projection: object | null, fileKey: number)` - Task 2 sends the fourth argument; without it the key is `undefined`, which behaves as a single photo, so this task is safe on its own.

- [ ] **Step 1: Add the per-file memory next to the rotate button**

Above `function rotatePanorama(viewer)`, add:

```js
// The rotation each photo was last left at, for this run only, keyed by the hash
// of its path (src/window.rs). A projection switch re-sends the same file and
// the viewer would otherwise come back level, and a photo opened again later
// comes back as it was left.
const rollByFile = new Map();

// The file the viewer is showing, so the rotate button knows whose rotation it
// is changing.
let currentFileKey;

function rememberedRoll(fileKey) {
return rollByFile.get(fileKey) ?? 0;
}
```

- [ ] **Step 2: Record the roll in `rotatePanorama`**

Keep the existing wrap of the roll into `[-PI, PI]`; store the result before applying it:

```js
function rotatePanorama(viewer) {
const correction = viewer.config.sphereCorrection ?? {};
// The option's parser keeps roll within [-PI, PI].
const roll = (((correction.roll ?? 0) + ROTATE_STEP + Math.PI) % (2 * Math.PI)) - Math.PI;
rollByFile.set(currentFileKey, roll);
viewer.setOption('sphereCorrection', { ...correction, roll });
}
```

- [ ] **Step 3: Apply the remembered roll in the loader**

Replace the reset block in `setPanoramaImageFromBase64` (the `const correction = ...` / `const options = ...` pair) and the `setPanorama` call with:

```js
// The roll belongs to the photo, not the projection: a flat view stays
// level, and the rotation waits for the dual fisheye view. Passing
// `sphereCorrection` every time says so, instead of relying on the
// viewer handing the last image's correction to the next one.
currentFileKey = fileKey;
const correction = window.SphereViewer.config.sphereCorrection ?? {};
const roll = projection ? rememberedRoll(fileKey) : 0;

setRotateButtonVisible(Boolean(projection));
window.SphereViewer.setPanorama(
projection ? { path: currentObjectUrl, projection } : currentObjectUrl,
{ sphereCorrection: { ...correction, roll } }
);
```

Change the function's signature line to
`window.setPanoramaImageFromBase64 = function (base64, mimeType, projection, fileKey) {`.

- [ ] **Step 4: Verify the page still builds and its tests pass**

Run: `cd resources/photosphereviewer && npm test && npm run build`
Expected: the `dual-fisheye` tests pass and Vite writes `dist/index.html` without errors.

- [ ] **Step 5: Commit**

```bash
git add resources/photosphereviewer/src/viewer.js
git commit -m "fix: keep a photo's rotation in the page, per file"
```

---

### Task 2: Rust tells the page which photo it is

**Files:**
- Modify: `src/window.rs` (imports, `file_key` helper, `render_panorama_with`)

**Interfaces:**
- Consumes: `setPanoramaImageFromBase64(base64, mimeType, projection, fileKey)` from Task 1.
- Produces: `fn file_key(path: Option<&Path>) -> u64` - a stable id for a path within a run.

- [ ] **Step 1: Add the key helper**

Widen the path import at the top of `src/window.rs` to `use std::path::{Path, PathBuf};` and add `use std::collections::hash_map::DefaultHasher;` plus `use std::hash::{Hash, Hasher};`.

Place this free function next to `impl Window`, above the `Projection` enum:

```rust
/// A stable id for a photo within this run, so the page can tell a file it has
/// already seen - a projection switch re-sends the same one - from a new one.
/// It keys the rotation the page remembers per photo. The demo image has no
/// path and hashes to whatever `None` hashes to, which is stable too.
fn file_key(path: Option<&Path>) -> u64 {
let mut hasher = DefaultHasher::new();
path.hash(&mut hasher);
hasher.finish()
}
```

- [ ] **Step 2: Send the key as the fourth argument**

In `render_panorama_with`, replace the `argument`/`params`/`js` construction with a call that always has four arguments, so a flat image sends `null` rather than dropping the parameter:

```rust
// Four arguments even for a flat image: the page keys each photo's
// rotation by the file key, which it needs whichever way the photo is
// shown.
let projection = imp
.projection()
.argument(panorama.dual_fisheye.as_deref())
.unwrap_or_else(|| "null".to_string());
let key = file_key(imp.active_panorama_file.borrow().as_deref());

let base64 = base64::engine::general_purpose::STANDARD.encode(&panorama.data);
let js = format!(
"window.setPanoramaImageFromBase64('{}', '{}', {}, {});",
base64, panorama.mime_type, projection, key
);
```

Update the comment above it ("The third argument is the projection, absent for an equirectangular image") to match the new `null` convention, and add the key to the `debug!` log line.

- [ ] **Step 3: Verify the build and the Rust tests**

Run: `cargo test`
Expected: all tests pass (`the_flat_mode_sends_no_projection`, `a_calibration_is_passed_through`, the trailer/mime/version tests). The build script runs `npm test` and `npm run build`; both must succeed.

- [ ] **Step 4: Commit**

```bash
git add src/window.rs
git commit -m "fix: key the remembered rotation by the photo's path"
```

---

### Task 3: Record the change

**Files:**
- Modify: `CHANGELOG`

- [ ] **Step 1: Add an unreleased heading with one line**

Follow the existing format (newest first, one bullet per user-visible change), above the `## v0.1.6` heading:

```markdown
## Unreleased

- Keep a photo's rotation when switching between the fisheye and equirectangular projections.
```

- [ ] **Step 2: Commit the changelog, spec and plan together**

```bash
git add CHANGELOG docs/superpowers
git commit -m "docs: record rotation memory across projections"
```

---

## After Implementation

By hand, in the app (the plan's only check for the page logic):

1. Open an `.insp` photo, rotate it a quarter turn.
2. Toggle the projection to equirectangular: the raw image is level, the rotate button is gone.
3. Toggle back: the quarter turn is there again.
4. Open a normal equirectangular photo: it is level, and no rotate button appears.
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Rotation survives a projection switch

Date: 2026-11-12
Status: approved

## Intent

The rotate button turns a dual fisheye photo in quarter turns, because an
Insta360 file carries no record of which way was up. Switching the projection
re-sends the same file to the viewer, and the viewer comes back level, so the
work of straightening a photo is thrown away by a look at the equirectangular
view. A rotation given to a photo belongs to that photo for as long as the app
runs; a projection switch is the same photo shown differently and must not lose
it.

## What is wrong today

`sphereCorrection.roll` is viewer-wide config, not per-image state. PSV's
`setPanorama` deliberately inherits it when the caller passes no
`sphereCorrection` (`@photo-sphere-viewer/core/index.module.js:6175-6183`), and
`viewer.js` zeroes the roll on every load, because an equirectangular image must
not arrive tilted. Every load looks the same to the page, so a projection switch
and a new file are indistinguishable there. The Rust side knows the difference
(`show_panorama` vs `refresh_panorama`).

Note that yaw, pitch and zoom already survive a re-projection - PSV leaves the
camera alone when `setPanorama` gets no options. The roll is the odd one out.

## Goals

- A rotation survives any number of projection switches, including
`photo A -> photo B -> photo A`.
- The equirectangular view of a rotated photo stays level; the rotation waits
for the dual fisheye view.
- A plain equirectangular photo never inherits a rotation.

## Non-goals

- Persistence of any kind: nothing on disk, nothing in the photo file, nothing
across restarts. The memory dies with the window.
- Rotation in equirectangular mode, where the button stays hidden.
- Bounded memory: the page keeps one small entry per photo opened in the run.

## Approaches

1. **A "new image" flag from Rust.** Smallest change, but returning to an
earlier photo loses its rotation. Rejected.
2. **Rotation remembered per file in the page, keyed by a hash of the path.**
Chosen: re-opening a photo restores its rotation, and the key is a number, so
no string escaping can go wrong and odd filenames need no special case.
3. **Never reset the roll.** Fewest lines, but a rotated fisheye photo followed
by a normal equirectangular photo would open tilted with no button to fix it.
Rejected.

## Design

- `resources/photosphereviewer/src/viewer.js`
- `const rollByFile = new Map()`, keyed by the numeric file key, holding the
roll a photo was last left at.
- `let currentFileKey` is set by the loader; the rotate button writes its new
roll into the map.
- The loader applies `roll: projection ? remembered : 0` and clears nothing,
always passing `sphereCorrection` so the intent is explicit rather than
relying on PSV's inheritance.
- Signature becomes
`setPanoramaImageFromBase64(base64, mimeType, projection, fileKey)`.
- `src/window.rs`
- `fn file_key(path: Option<&Path>) -> u64` hashes the path with
`DefaultHasher`; the demo image has no path and gets `0`.
- `render_panorama_with` passes the key from `active_panorama_file` as the
fourth argument, and the projection as `null` when the file is flat, so the
call always has four arguments.
- `CHANGELOG`: one line under a new unreleased heading.

## What is left to the builder

- Naming and comment wording, in the repo's existing why-focused style.
- No new tests (decided with the partner): the page logic is exercised by hand,
the snippet stays inline in `render_panorama_with` since nothing would test an
extracted builder.
2 changes: 1 addition & 1 deletion io.github.dynobo.sphereview.desktop
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@ Terminal=false
Type=Application
Categories=Graphics;Viewer;
Keywords=Photosphere;Panorama;SphereView;
MimeType=image/png;image/jpeg;image/jpg;image/webp;
MimeType=image/png;image/jpeg;image/jpg;image/webp;image/x-insta360-insp;
19 changes: 19 additions & 0 deletions io.github.dynobo.sphereview.mime.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">
<!-- Insta360 photos. An .insp is a JPEG with the camera's own trailer after
it, holding both lenses' fisheye circles side by side.

The trader ends the file, and shared-mime-info can only match at offsets
counted from the start, so the trailer cannot be used to recognise the
type: it is matched by extension instead.

Deliberately not declared as a subclass of image/jpeg. It is one, but a
reader that treats it as an ordinary JPEG shows the two circles, so it
should not be handed to JPEG viewers and thumbnailers by inheritance. -->
<mime-type type="image/x-insta360-insp">
<comment>Insta360 photo</comment>
<comment xml:lang="de">Insta360-Foto</comment>
<icon name="io.github.dynobo.sphereview"/>
<glob pattern="*.insp" case-sensitive="false"/>
</mime-type>
</mime-info>
Binary file added resources/gschemas.compiled
Binary file not shown.
3 changes: 2 additions & 1 deletion resources/photosphereviewer/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion resources/photosphereviewer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "node --test src/*.test.js",
"preview": "vite preview",
"debug": "http-server ./dist -p 8080 -o index.html"
},
Expand All @@ -17,6 +18,7 @@
},
"dependencies": {
"@photo-sphere-viewer/autorotate-plugin": "^5.15.1",
"@photo-sphere-viewer/core": "^5.15.1"
"@photo-sphere-viewer/core": "^5.15.1",
"three": "^0.185.1"
}
}
Loading
Loading