screentinker/server/lib/media-orientation.js
screentinker 837f65e634
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
fix(content+android): rotation-aware media — portrait upright on dashboard AND player (#170) (#172)
* fix(content): rotation-aware media dimensions — portrait no longer stored landscape (#170)

Ingest recorded CODED width/height and ignored rotation, so a portrait phone video
(coded 1920x1080 + 90° Display-Matrix) or a portrait photo (EXIF orientation 6) was
stored LANDSCAPE. The player then rendered it wrong-aspect and letterboxed — the
"portrait content degraded + blue bar at the bottom" symptom in #170. The reporter's
workaround (pre-rotate + mark Landscape) is exactly what this bug forces.

- lib/media-orientation.js (new): pure, unit-tested display-dimension helpers = single
  source of truth for ingest AND the backfill. videoDisplayDims() reads the modern
  Display-Matrix side_data rotation (falls back to the legacy tags.rotate, sign-normalized);
  imageDisplayDims() honors EXIF orientation 5..8. Odd quarter-turns swap W/H.
- lib/content-ingest.js: use the helpers for stored dims; add sharp .rotate() so image
  THUMBNAILS are auto-oriented too (video thumbs were already auto-rotated by ffmpeg).
- scripts/backfill-rotation-dims.js (new): idempotent, dry-run-by-default maintenance to
  correct already-uploaded portrait media (re-probe -> fix dims -> regenerate image thumbs).
- test/media-orientation.test.js: 5 bites (tag + Display-Matrix, sign/normalize, EXIF 5..8,
  the blue-bar landscape->portrait case, null-safety).

Scopes #170 to its residual-on-1.9.4 issues; the 1.9.3 "never displays" slice was #162 +
the remote_url-null download fix, already shipped in 1.9.4. The slow low-res/orientation-
cycling first load is tracked separately in #170 pending repro data.

Refs #170.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(android): honor EXIF orientation in ImageLoader so portrait photos render upright (#170)

Completes the rotation-aware media fix on the PLAYER side. The server ingest fix (this
branch) corrects stored dimensions + auto-orients the thumbnail, but the panel draws the
full-res original via BitmapFactory, which ignores EXIF — so a portrait photo (landscape
pixels tagged "rotate 90") still rendered sideways on the screen. QA root-cause pass on
#170 caught this gap: the Android player reads no stored dims and applied no EXIF.

ImageLoader now reads the EXIF orientation (from the file for cached content, from the byte
stream for remote_url images — ExifInterface(stream) is API 24+, minSdk is 24) and rotates/
flips the decoded bitmap via a Matrix (all 8 orientations). NORMAL/UNDEFINED is a no-op (no
extra allocation); a transformed copy recycles the source; OOM falls back to the source
rather than crashing. Videos were already correct (ExoPlayer honors the rotation matrix).

Verified: :app:compileDebugKotlin clean.

Refs #170. Rides with the server rotation-dims fix on this branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:05:11 -05:00

56 lines
2.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
// #170: rotation-aware media dimensions. Both ffprobe (video) and sharp/EXIF (image) report the
// CODED/stored width×height, which ignore how the asset is meant to be DISPLAYED. A portrait
// phone video (coded 1920×1080 with a 90° display matrix) or a portrait photo (EXIF orientation
// 6) is therefore stored as LANDSCAPE, so the player renders it wrong-aspect and letterboxed
// (the blue-bar symptom). These pure helpers normalize to DISPLAY dimensions and are the single
// source of truth for both fresh ingest (content-ingest.js) and the backfill script. Unit-tested
// in test/media-orientation.test.js.
// Does a rotation of `degrees` swap width and height? True only for odd quarter-turns.
function rotationSwapsWH(degrees) {
const d = (((Number(degrees) || 0) % 360) + 360) % 360;
return d === 90 || d === 270;
}
// Rotation (degrees, normalized 0..359) a video should be displayed at. ffprobe exposes this two
// ways: the legacy `tags.rotate` string, and the modern Display Matrix side_data `rotation` (an
// int, often NEGATIVE, e.g. -90). Prefer the Display Matrix when present (what modern muxers
// write); fall back to the tag. The sign is irrelevant to the W/H swap decision either way.
function videoRotationDegrees(videoStream) {
if (!videoStream) return 0;
let deg = null;
const dm = (videoStream.side_data_list || []).find(s => /display\s*matrix/i.test(s.side_data_type || ''));
if (dm && dm.rotation != null && !Number.isNaN(parseInt(dm.rotation, 10))) {
deg = parseInt(dm.rotation, 10);
} else if (videoStream.tags && videoStream.tags.rotate != null && !Number.isNaN(parseInt(videoStream.tags.rotate, 10))) {
deg = parseInt(videoStream.tags.rotate, 10);
}
return (((Number(deg) || 0) % 360) + 360) % 360;
}
// Display dimensions for a video stream, honoring rotation. Null-safe.
function videoDisplayDims(videoStream) {
if (!videoStream || videoStream.width == null || videoStream.height == null) return { width: null, height: null };
return rotationSwapsWH(videoRotationDegrees(videoStream))
? { width: videoStream.height, height: videoStream.width }
: { width: videoStream.width, height: videoStream.height };
}
// EXIF orientation 5..8 encode a 90°/270° rotation (W/H swap); 1..4 do not.
function exifSwapsWH(orientation) {
const o = Number(orientation);
return o >= 5 && o <= 8;
}
// Display dimensions for an image, honoring EXIF orientation. Null-safe.
function imageDisplayDims(metadata) {
if (!metadata || metadata.width == null || metadata.height == null) return { width: null, height: null };
return exifSwapsWH(metadata.orientation)
? { width: metadata.height, height: metadata.width }
: { width: metadata.width, height: metadata.height };
}
module.exports = { rotationSwapsWH, videoRotationDegrees, videoDisplayDims, exifSwapsWH, imageDisplayDims };