mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 22:33:12 -06:00
Describe a portrait video wall as portrait, and stop a wall hiding its screens
#236: the wall canvas was secretly framebuffer space rather than the wall as the audience sees it. Invisible while every panel is the normal way up, and actively misleading the moment one isn't — two portrait-mounted panels standing side by side had to be STACKED VERTICALLY in the editor, with a pre-rotated copy of every video, before the output came out right. It worked, but only after trial and error, and it meant a portrait wall could never reuse content as-is. Each panel now carries a mounting rotation (0/90/180/270 clockwise, the same convention as the per-device orientation setting), the canvas means the physical wall, and the player works out the mapping. The geometry lives in one place, server/lib/wall-geometry.js, because four players have to agree on it to the pixel across a seam. Existing walls need no migration and do not move. Every wall in the field is rotation 0, and that case takes the original expression verbatim on all three players rather than the algebraically-equal centre-based one — the two differ in the last float bit, and a float's worth of disagreement between two panels is a hairline seam down a wall that was aligned yesterday. Pinned by the first test in wall-geometry.test.js and by wall-payload.test.js. While a display is in a wall its panel rotation replaces its own orientation: both describe the same physical fact, so honouring both turned the content twice. #235: a wall replaced its members' cards, so one dead panel of a four-panel wall was invisible from the dashboard, and inspecting a single screen meant pulling it out of the live wall and putting it back. The wall screen now lists its panels with live online state, a per-panel screenshot request, and a link to each device's page; the dashboard wall card carries per-member status chips that track socket updates. Tests: wall-geometry.test.js re-simulates the CSS box independently and asserts each panel's viewport maps onto exactly its own rect of wall space, for every rotation, plus a mixed wall and the Tizen player's hand-ported copy executed against the canonical rule. Full server suite green (1260). Not verified here: the Android and Tizen renders on real hardware. Kotlin compiles clean; the maths is shared/tested, the view plumbing is not.
This commit is contained in:
parent
afe3f7f57f
commit
e4c25c39df
28
CHANGELOG.md
28
CHANGELOG.md
|
|
@ -1,5 +1,33 @@
|
|||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixed — a wall of portrait panels had to be built backwards (#236)
|
||||
The wall canvas was secretly framebuffer space, not the wall as you see it. That is invisible while
|
||||
every panel is the normal way up, and actively misleading the moment one isn't: two portrait-mounted
|
||||
panels standing side by side had to be **stacked vertically** in the editor, with a pre-rotated copy
|
||||
of every video, before the output came out right. It worked, but only after trial and error, and it
|
||||
meant a portrait wall could never reuse existing content.
|
||||
|
||||
Each panel now carries a mounting rotation (0/90/180/270), the canvas means the physical wall, and
|
||||
the player works out the mapping — so side by side is drawn side by side and landscape content plays
|
||||
across portrait panels unmodified. Applied on the web, Tizen and Android players.
|
||||
|
||||
**Existing walls are untouched and need no migration.** Every wall in the field is rotation 0, which
|
||||
takes the original code path verbatim — an operator who upgrades will not find a wall that was
|
||||
aligned yesterday has moved. Rebuilding an existing portrait wall the natural way round is an opt-in
|
||||
change the operator makes when they choose to.
|
||||
|
||||
While a display is a member of a wall, its per-panel rotation replaces its own Orientation setting:
|
||||
the two describe the same physical fact, and honouring both turned the content twice.
|
||||
|
||||
### Added — a wall no longer hides its own screens (#235)
|
||||
Grouping displays into a wall replaced their individual cards, so one dead panel of a four-panel
|
||||
wall was invisible from the dashboard, and inspecting a single screen meant pulling it out of the
|
||||
wall (re-syncing the live wall) and putting it back. The wall screen now lists its panels with live
|
||||
online state and a link straight to each device's page, and the wall card on the dashboard shows a
|
||||
per-member status chip. A screenshot can be requested per panel without disturbing playback.
|
||||
|
||||
## 1.9.29
|
||||
|
||||
The release candidates 1.9.29-rc1 through rc5 are folded in here; the entries below record what
|
||||
|
|
|
|||
|
|
@ -447,8 +447,17 @@ class MainActivity : AppCompatActivity() {
|
|||
// Video-wall slice transform. The content view represents the whole wall (player_rect);
|
||||
// size + offset rootView so this screen's screen_rect fills the device viewport, content
|
||||
// stretched to fill (object-fit:fill parity, set on the views via MediaPlayerManager).
|
||||
// Mirrors the web player's vw/vh stage math. Per-tile rotation is intentionally not
|
||||
// applied (web/Tizen parity). cfg == null restores full screen.
|
||||
// Mirrors the web player's vw/vh stage math. cfg == null restores full screen.
|
||||
//
|
||||
// #236: per-panel mounting rotation is now applied. Ported by hand from
|
||||
// server/lib/wall-geometry.js, which is the canonical rule and the only place it is tested —
|
||||
// Kotlin cannot load the shared script the web player pulls, so any change there has to be
|
||||
// mirrored here or a wall of mixed players grows a seam. `rotation` is degrees CLOCKWISE the
|
||||
// content is turned inside the framebuffer (the same convention as the orientation setting),
|
||||
// and Android's View.rotation is clockwise-positive too, so it maps straight across.
|
||||
//
|
||||
// Transform order matters: Android rotates about the pivot (view centre) and THEN applies
|
||||
// translation, so the translation is computed to land the view's CENTRE, not its top-left.
|
||||
private fun applyWallTransform(cfg: WallController.WallConfig?) {
|
||||
val lp = rootView.layoutParams
|
||||
if (cfg == null) {
|
||||
|
|
@ -476,19 +485,49 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
val dw = resources.displayMetrics.widthPixels.toFloat()
|
||||
val dh = resources.displayMetrics.heightPixels.toFloat()
|
||||
lp.width = ((p.w / s.w) * dw).toInt()
|
||||
lp.height = ((p.h / s.h) * dh).toInt()
|
||||
rootView.layoutParams = lp
|
||||
rootView.translationX = ((p.x - s.x) / s.w) * dw // negative for right/lower tiles
|
||||
rootView.translationY = ((p.y - s.y) / s.h) * dh
|
||||
rootView.rotation = 0f // per-tile rotation: TODO (parity = none)
|
||||
val rot = when (cfg.rotation) { 90 -> 90; 180 -> 180; 270 -> 270; else -> 0 }
|
||||
|
||||
if (rot == 0) {
|
||||
// Left byte-identical to the pre-#236 expression on purpose: every wall in the field is
|
||||
// rotation 0, and an operator who updates must not find a wall that was aligned
|
||||
// yesterday has shifted by a rounding error.
|
||||
lp.width = ((p.w / s.w) * dw).toInt()
|
||||
lp.height = ((p.h / s.h) * dh).toInt()
|
||||
rootView.layoutParams = lp
|
||||
rootView.translationX = ((p.x - s.x) / s.w) * dw // negative for right/lower tiles
|
||||
rootView.translationY = ((p.y - s.y) / s.h) * dh
|
||||
rootView.rotation = 0f
|
||||
} else {
|
||||
// A quarter turn measures the wall's horizontal against the framebuffer's VERTICAL —
|
||||
// on a panel hung sideways, moving right along the wall moves down the display.
|
||||
val quarter = (rot == 90 || rot == 270)
|
||||
val boxW = (p.w / s.w) * (if (quarter) dh else dw)
|
||||
val boxH = (p.h / s.h) * (if (quarter) dw else dh)
|
||||
// Where the player rect's centre sits within this panel's rect, 0..1 in wall space.
|
||||
val nx = (p.x + p.w / 2f - s.x) / s.w
|
||||
val ny = (p.y + p.h / 2f - s.y) / s.h
|
||||
// ...and where that lands in the framebuffer once the panel's turn is undone.
|
||||
val cx: Float
|
||||
val cy: Float
|
||||
when (rot) {
|
||||
90 -> { cx = 1f - ny; cy = nx }
|
||||
180 -> { cx = 1f - nx; cy = 1f - ny }
|
||||
else -> { cx = ny; cy = 1f - nx } // 270
|
||||
}
|
||||
lp.width = boxW.toInt()
|
||||
lp.height = boxH.toInt()
|
||||
rootView.layoutParams = lp
|
||||
rootView.rotation = rot.toFloat()
|
||||
rootView.translationX = cx * dw - boxW / 2f
|
||||
rootView.translationY = cy * dh - boxH / 2f
|
||||
}
|
||||
rootView.scaleX = 1f
|
||||
rootView.scaleY = 1f
|
||||
rootView.requestLayout()
|
||||
mirrorTransformToPip()
|
||||
// Orientation no longer reflects reality; ensure it re-applies after wall exit.
|
||||
currentOrientation = null
|
||||
Log.i("MainActivity", "Wall transform: size=${lp.width}x${lp.height} tx=${rootView.translationX} ty=${rootView.translationY}")
|
||||
Log.i("MainActivity", "Wall transform: size=${lp.width}x${lp.height} tx=${rootView.translationX} ty=${rootView.translationY} rot=$rot")
|
||||
}
|
||||
|
||||
private fun setupServiceCallbacks() {
|
||||
|
|
|
|||
|
|
@ -1560,7 +1560,18 @@ paths:
|
|||
device_id: { type: string }
|
||||
grid_col: { type: integer }
|
||||
grid_row: { type: integer }
|
||||
rotation: { type: integer }
|
||||
rotation:
|
||||
type: integer
|
||||
enum: [0, 90, 180, 270]
|
||||
default: 0
|
||||
description: >-
|
||||
How this panel is physically mounted, as degrees CLOCKWISE that its image
|
||||
must be turned to come out upright on the wall (the same convention as a
|
||||
device's `orientation`). canvas_* are in WALL space — the wall as the
|
||||
audience sees it — so a portrait-mounted 1920x1080 panel is a tall tile
|
||||
with rotation 90, and content needs no pre-rotating. Anything other than
|
||||
0/90/180/270 is stored as 0. While a panel is in a wall this replaces its
|
||||
own `orientation`, so the two can never rotate the content twice.
|
||||
canvas_x: { type: number }
|
||||
canvas_y: { type: number }
|
||||
canvas_width: { type: number }
|
||||
|
|
|
|||
|
|
@ -174,7 +174,9 @@ function renderWallCard(wall) {
|
|||
cells.push(`<div class="wall-card-cell${dev ? ' filled' : ''}" title="${dev ? esc(dev.device_name) : '[' + c + ',' + r + ']'}"></div>`);
|
||||
}
|
||||
}
|
||||
const onlineCount = (wall.devices || []).filter(d => d.device_status === 'online').length;
|
||||
const members = wall.devices || [];
|
||||
const onlineCount = members.filter(d => d.device_status === 'online').length;
|
||||
const allUp = onlineCount === members.length && members.length > 0;
|
||||
return `
|
||||
<div class="device-card wall-card" data-wall-id="${wall.id}" onclick="window.location.hash='#/wall/${wall.id}'">
|
||||
<div class="device-card-preview wall-card-preview">
|
||||
|
|
@ -187,8 +189,20 @@ function renderWallCard(wall) {
|
|||
<div class="device-card-body">
|
||||
<div class="device-card-name">${esc(wall.name)}</div>
|
||||
<div class="device-card-meta">
|
||||
<div class="meta-item">${(wall.devices || []).length} ${(wall.devices || []).length === 1 ? 'tile' : 'tiles'}</div>
|
||||
<div class="meta-item" style="color:${onlineCount === (wall.devices || []).length ? 'var(--success)' : 'var(--text-muted)'}">${onlineCount} online</div>
|
||||
<div class="meta-item">${members.length} ${members.length === 1 ? 'tile' : 'tiles'}</div>
|
||||
<div class="meta-item" style="color:${allUp ? 'var(--success)' : 'var(--danger, #e5484d)'}">${allUp ? 'all online' : `${onlineCount}/${members.length} online`}</div>
|
||||
</div>
|
||||
<!-- #235: a wall replaces its members' cards, so without this strip one dead panel of a
|
||||
four-panel wall is invisible from the dashboard. Each chip links straight to the
|
||||
device page — being in a wall must not cost device-level visibility. -->
|
||||
<div class="wall-card-members" style="display:flex;flex-wrap:wrap;gap:4px;margin-top:8px">
|
||||
${members.map(d => `
|
||||
<a class="wall-card-member" href="#/device/${esc(d.device_id)}" data-member-device-id="${esc(d.device_id)}" onclick="event.stopPropagation()"
|
||||
title="${esc(d.device_name)} — ${esc(d.device_status || 'unknown')}. Open device info & controls"
|
||||
style="display:inline-flex;align-items:center;gap:4px;max-width:120px;padding:1px 6px;border:1px solid var(--border);border-radius:10px;font-size:10px;color:var(--text-secondary);text-decoration:none">
|
||||
<span class="status-dot ${esc(d.device_status || 'offline')}" style="display:inline-block;flex-shrink:0"></span>
|
||||
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(d.device_name)}</span>
|
||||
</a>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -405,6 +419,14 @@ export function render(container) {
|
|||
const statusEl = card.querySelector('.device-card-status');
|
||||
if (statusEl) statusEl.innerHTML = `<span class="device-status-badge ${b.state}" data-liveness="${b.state}" data-offline-reason="${esc(b.reason)}"${b.title ? ` title="${esc(b.title)}"` : ''}>${esc(b.label)}</span>`;
|
||||
});
|
||||
// #235: a wall member has no card of its own, only a chip on the wall card. Without this a
|
||||
// panel could go offline and the dashboard would keep showing it green until a full reload —
|
||||
// exactly the blind spot the issue is about.
|
||||
document.querySelectorAll(`.wall-card-member[data-member-device-id="${CSS.escape(data.device_id)}"]`).forEach(chip => {
|
||||
const dot = chip.querySelector('.status-dot');
|
||||
if (dot) dot.className = `status-dot ${b.state}`;
|
||||
chip.title = `${chip.title.split(' — ')[0]} — ${b.label}. Open device info & controls`;
|
||||
});
|
||||
};
|
||||
|
||||
screenshotHandler = (data) => {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export function render(container) {
|
|||
{ icon: '📅', title: 'Content Scheduling', steps: ['Go to Schedule and select a device', 'Click "Add Schedule" to create a time slot', 'Set start/end times and recurrence rules', 'Higher priority schedules override lower ones', 'Content auto-switches based on the schedule'] },
|
||||
{ icon: '🖥', title: 'Remote Control', steps: ['Go to a device\'s detail page', 'Click the "Remote Control" tab', 'Click "Start Remote" to begin streaming', 'Use the d-pad, volume, and power buttons', 'Click anywhere on the screen to simulate a tap'] },
|
||||
{ icon: '🖱', title: 'Kiosk/Touchscreen', steps: ['Go to Kiosk and create a new page', 'Add buttons with labels, icons, and actions', 'Configure the idle screen timeout', 'Preview the page in the editor', 'Assign to a device as a widget'] },
|
||||
{ icon: '🎬', title: 'Video Walls', steps: ['Go to Video Walls and create a new wall', 'Set the grid size (e.g., 2x2)', 'Drag devices onto grid positions', 'Set bezel compensation if needed', 'Assign content to play across all displays'] },
|
||||
{ icon: '🎬', title: 'Video Walls', steps: ['Go to Video Walls and create a new wall', 'Drag displays onto the canvas and arrange them to match the PHYSICAL wall', 'Panel hung sideways? Select it and set "How this panel is mounted" — no need to pre-rotate your video', 'Set bezel compensation if needed, then "Fit player to screens"', 'Assign a playlist to play across all displays', 'The Panels list below the canvas shows each screen\'s online state and links to its device page'] },
|
||||
].map(guide => `
|
||||
<div class="settings-section" style="margin:0">
|
||||
<h3 style="font-size:15px">${guide.icon} ${guide.title}</h3>
|
||||
|
|
@ -44,6 +44,7 @@ export function render(container) {
|
|||
{ q: 'Can I white-label the dashboard?', a: 'Yes! Go to Settings > White Label to customize the brand name, colors, logo, and domain.' },
|
||||
{ q: 'How do I export proof-of-play reports?', a: 'Go to Reports, set your date range and filters, then click "Export CSV".' },
|
||||
{ q: 'What is a video wall?', a: 'A video wall combines multiple displays into one large screen. For example, four TVs in a 2x2 grid showing one big image/video.' },
|
||||
{ q: 'How do I build a wall from portrait (sideways-mounted) panels?', a: 'Arrange the tiles on the wall canvas exactly as the panels are hung — side by side stays side by side. Then select each tile and set "How this panel is mounted" to match how it was turned. The player rotates the content for you, so you do not need a pre-rotated copy of your video. While a display is in a wall, this setting replaces its own Orientation.' },
|
||||
].map(faq => `
|
||||
<div style="border-bottom:1px solid var(--border);padding:12px 0">
|
||||
<div style="font-weight:600;font-size:14px;margin-bottom:4px">${faq.q}</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { api } from '../api.js';
|
||||
import { on, off, requestScreenshot } from '../socket.js';
|
||||
import { showToast } from '../components/toast.js';
|
||||
import { esc } from '../utils.js';
|
||||
import { esc, livenessBadge } from '../utils.js';
|
||||
import { t } from '../i18n.js';
|
||||
|
||||
const API = (url, opts = {}) => fetch('/api' + url, {
|
||||
|
|
@ -17,6 +18,25 @@ const CANVAS_MIN_W = 1200;
|
|||
const CANVAS_MIN_H = 700;
|
||||
const CANVAS_PADDING = 200; // extra room beyond bounding box, in canvas units
|
||||
|
||||
// #236: how far a panel's own image has to be turned to come out upright on the wall — i.e. how
|
||||
// far the panel itself is hung the other way. Degrees CLOCKWISE, matching the per-device
|
||||
// orientation setting; the render rule lives in server/lib/wall-geometry.js.
|
||||
//
|
||||
// Before this existed the canvas was secretly FRAMEBUFFER space, so a customer with two portrait
|
||||
// panels side by side had to stack them vertically here and pre-rotate every video. The canvas is
|
||||
// now what it always looked like: the wall as the audience sees it.
|
||||
const WALL_ROTATIONS = [0, 90, 180, 270];
|
||||
const ROTATION_LABELS = { 0: 'Normal (0°)', 90: 'Turned left (90°)', 180: 'Upside down (180°)', 270: 'Turned right (270°)' };
|
||||
// A panel already configured portrait is already hung sideways; carry that across so the operator
|
||||
// doesn't have to say the same thing twice (and so the tile lands the right shape first time).
|
||||
const ORIENTATION_TO_ROTATION = { 'landscape': 0, 'portrait': 90, 'landscape-flipped': 180, 'portrait-flipped': 270 };
|
||||
|
||||
// Mirrors rotatedFootprint() in server/lib/wall-geometry.js — kept tiny and local because the
|
||||
// dashboard has no import path to the server lib.
|
||||
function footprintFor(renderW, renderH, rotation) {
|
||||
return (rotation === 90 || rotation === 270) ? { w: renderH, h: renderW } : { w: renderW, h: renderH };
|
||||
}
|
||||
|
||||
export async function render(container) {
|
||||
const hash = window.location.hash;
|
||||
if (hash.startsWith('#/wall/')) {
|
||||
|
|
@ -105,6 +125,14 @@ async function renderWallEditor(container, wallId) {
|
|||
if (d && d.render_width > 0 && d.render_height > 0) return { w: d.render_width, h: d.render_height };
|
||||
return { w: DEFAULT_SCREEN_W, h: DEFAULT_SCREEN_H };
|
||||
};
|
||||
// #236: the tile is the panel's footprint ON THE WALL, so a sideways-hung panel is a tall tile.
|
||||
const footprintOnWall = (id, rotation) => {
|
||||
const r = renderSizeFor(id);
|
||||
return footprintFor(r.w, r.h, rotation);
|
||||
};
|
||||
// A device already set to portrait is already hung sideways — start it there rather than making
|
||||
// the operator discover the rotation control after the wall comes out wrong.
|
||||
const defaultRotationFor = (id) => ORIENTATION_TO_ROTATION[deviceById(id)?.orientation] || 0;
|
||||
// When the panel's physical resolution differs from what it renders (rotated mount:
|
||||
// the box reports 800x1332 but draws 1332x800), the tile size can look "wrong". Return a
|
||||
// short note making explicit that the tile is sized to the RENDER surface, not the panel.
|
||||
|
|
@ -127,8 +155,10 @@ async function renderWallEditor(container, wallId) {
|
|||
rotation: d.rotation || 0,
|
||||
x: d.canvas_x ?? (d.grid_col * (baseW + bezelH)),
|
||||
y: d.canvas_y ?? (d.grid_row * (baseH + bezelV)),
|
||||
w: d.canvas_width ?? renderSizeFor(d.device_id).w,
|
||||
h: d.canvas_height ?? renderSizeFor(d.device_id).h,
|
||||
// Backfill sizes as the panel's footprint at its saved rotation. Every wall in the field is
|
||||
// rotation 0, where this is exactly the old expression — so nothing existing moves.
|
||||
w: d.canvas_width ?? footprintOnWall(d.device_id, d.rotation || 0).w,
|
||||
h: d.canvas_height ?? footprintOnWall(d.device_id, d.rotation || 0).h,
|
||||
}));
|
||||
|
||||
// Default player covers the bounding box of all screens; if there are no
|
||||
|
|
@ -224,6 +254,19 @@ async function renderWallEditor(container, wallId) {
|
|||
</select>
|
||||
<button class="btn btn-primary btn-sm" id="setPlaylistBtn" style="margin-left:8px">${t('wall.set_playlist')}</button>
|
||||
</div>
|
||||
|
||||
<!-- #235: a wall used to swallow its members whole — joining one removed the device's card
|
||||
from Displays, so an operator could not see that one panel of a four-panel wall had
|
||||
dropped off, and the only way to check a single screen was to pull it out of the wall
|
||||
(which re-syncs the live wall) and put it back. Panel state and a way through to the
|
||||
device live here now. -->
|
||||
<div style="margin-top:20px">
|
||||
<h3 style="font-size:14px;margin:0 0 8px;display:flex;align-items:center;gap:10px">
|
||||
Panels
|
||||
<span id="wallPanelSummary" style="font-size:12px;font-weight:400;color:var(--text-muted)"></span>
|
||||
</h3>
|
||||
<div id="wallPanelStatus"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="width:260px;flex-shrink:0">
|
||||
|
|
@ -234,9 +277,13 @@ async function renderWallEditor(container, wallId) {
|
|||
<div class="info-card" style="margin-top:14px;padding:10px;font-size:12px;line-height:1.55">
|
||||
<strong style="font-size:12px">How it works</strong>
|
||||
<ul style="margin:6px 0 0 14px;padding:0;color:var(--text-secondary)">
|
||||
<li>This canvas is the wall <strong>as the audience sees it</strong>. Arrange the
|
||||
rectangles to match the physical layout.</li>
|
||||
<li>Each rectangle is a physical screen.</li>
|
||||
<li>The blue dashed rectangle is the player window — content plays inside this rect.</li>
|
||||
<li>Each screen shows only the part of the player that overlaps it.</li>
|
||||
<li>Panel hung sideways? Select it and set <em>How this panel is mounted</em> — the
|
||||
tile turns to match and the content is rotated for you.</li>
|
||||
<li>Drag corners to resize, drag the body to move.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -252,6 +299,7 @@ async function renderWallEditor(container, wallId) {
|
|||
for (const s of screens) canvas.appendChild(renderScreenEl(s));
|
||||
updateOverlapsAll();
|
||||
renderSidebar();
|
||||
renderPanelStatus();
|
||||
applySelectionClasses();
|
||||
renderSelectionPanel();
|
||||
applyTransform();
|
||||
|
|
@ -287,12 +335,43 @@ async function renderWallEditor(container, wallId) {
|
|||
<label>W</label><input type="number" data-field="w" value="${Math.round(rect.w)}" step="1" min="40">
|
||||
<label>H</label><input type="number" data-field="h" value="${Math.round(rect.h)}" step="1" min="24">
|
||||
</div>
|
||||
${isPlayer ? '' : `
|
||||
<div style="margin-top:10px">
|
||||
<label style="font-size:11px;color:var(--text-muted);display:block;margin-bottom:3px">How this panel is mounted</label>
|
||||
<select id="screenRotation" class="input" style="width:100%;font-size:12px;background:var(--bg-input)">
|
||||
${WALL_ROTATIONS.map(r => `<option value="${r}" ${(rect.rotation || 0) === r ? 'selected' : ''}>${ROTATION_LABELS[r]}</option>`).join('')}
|
||||
</select>
|
||||
<p style="margin:5px 0 0;font-size:10px;color:var(--text-muted);line-height:1.4">
|
||||
Lay the canvas out to match the <strong>physical</strong> wall and set this per panel —
|
||||
content is rotated for you, so portrait walls don't need pre-rotated video.
|
||||
While a panel is in a wall this replaces its own Orientation setting.
|
||||
</p>
|
||||
</div>`}
|
||||
<p style="margin:8px 0 0;font-size:10px;color:var(--text-muted);line-height:1.4">
|
||||
Arrow keys nudge by 1px. Hold <kbd>Shift</kbd> for 10px.
|
||||
Click outside any rect to deselect.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
panel.querySelector('#screenRotation')?.addEventListener('change', (ev) => {
|
||||
const r = getSelectedRect();
|
||||
if (!r) return;
|
||||
const next = parseInt(ev.target.value, 10) || 0;
|
||||
const prev = r.rotation || 0;
|
||||
r.rotation = next;
|
||||
// Turning a panel by a quarter turn changes its footprint on the wall. Swap the tile about
|
||||
// its own CENTRE so it turns in place — resizing from the corner would shove every
|
||||
// neighbouring tile out of alignment and undo the operator's careful placement.
|
||||
const wasQuarter = (prev === 90 || prev === 270);
|
||||
const isQuarter = (next === 90 || next === 270);
|
||||
if (wasQuarter !== isQuarter) {
|
||||
const cx = r.x + r.w / 2, cy = r.y + r.h / 2;
|
||||
const w = r.h, h = r.w;
|
||||
r.w = w; r.h = h; r.x = cx - w / 2; r.y = cy - h / 2;
|
||||
}
|
||||
markDirty();
|
||||
renderAll();
|
||||
});
|
||||
panel.querySelector('#deselectBtn').addEventListener('click', () => {
|
||||
selected = null;
|
||||
applySelectionClasses();
|
||||
|
|
@ -384,6 +463,7 @@ async function renderWallEditor(container, wallId) {
|
|||
<div class="wall-screen-meta">
|
||||
<span class="status-dot ${s.device_status}" style="display:inline-block"></span>
|
||||
<span style="font-size:10px;color:var(--text-muted)">${Math.round(s.w)}×${Math.round(s.h)}</span>
|
||||
${(s.rotation || 0) !== 0 ? `<span class="wall-screen-rot" title="${esc(ROTATION_LABELS[s.rotation])}" style="font-size:10px;color:var(--accent);margin-left:4px">⟳${s.rotation}°</span>` : ''}
|
||||
</div>
|
||||
${renderNoteFor(s.device_id) ? `<div class="wall-screen-rendernote" style="font-size:9px;color:var(--warning,#e0a800);margin-top:2px;line-height:1.2">${esc(renderNoteFor(s.device_id))}</div>` : ''}
|
||||
</div>
|
||||
|
|
@ -453,6 +533,88 @@ async function renderWallEditor(container, wallId) {
|
|||
});
|
||||
}
|
||||
|
||||
// #235: per-panel state for the wall, with a way through to the device itself.
|
||||
// Live socket updates are merged over the fetched rows so a panel that drops off mid-session
|
||||
// turns red here without a reload — "is the wall actually up?" has to be answerable from the
|
||||
// dashboard, because today it means sending someone to look at it.
|
||||
const liveStatus = {};
|
||||
function panelLiveness(deviceId) {
|
||||
const d = deviceById(deviceId) || {};
|
||||
const live = liveStatus[deviceId];
|
||||
return livenessBadge({ ...d, ...(live || {}) }, { short: true });
|
||||
}
|
||||
|
||||
function renderPanelStatus() {
|
||||
const host = document.getElementById('wallPanelStatus');
|
||||
if (!host) return;
|
||||
const summary = document.getElementById('wallPanelSummary');
|
||||
|
||||
if (screens.length === 0) {
|
||||
host.innerHTML = `<p style="color:var(--text-muted);font-size:12px;margin:0">No panels on this wall yet — drag displays onto the canvas.</p>`;
|
||||
if (summary) summary.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const badges = screens.map(s => ({ s, b: panelLiveness(s.device_id) }));
|
||||
const down = badges.filter(x => x.b.state !== 'online').length;
|
||||
if (summary) {
|
||||
summary.textContent = down === 0
|
||||
? `${screens.length} panel${screens.length === 1 ? '' : 's'}, all online`
|
||||
: `${down} of ${screens.length} not online`;
|
||||
summary.style.color = down === 0 ? 'var(--success)' : 'var(--danger, #e5484d)';
|
||||
}
|
||||
|
||||
host.innerHTML = `
|
||||
<div class="wall-panel-list" style="display:flex;flex-direction:column;gap:6px">
|
||||
${badges.map(({ s, b }) => {
|
||||
const d = deviceById(s.device_id) || {};
|
||||
const meta = [
|
||||
d.app_version ? `v${esc(d.app_version)}` : '',
|
||||
(s.rotation || 0) !== 0 ? esc(ROTATION_LABELS[s.rotation]) : '',
|
||||
].filter(Boolean).join(' · ');
|
||||
return `
|
||||
<div class="playlist-item" data-device-id="${esc(s.device_id)}" style="display:flex;align-items:center;gap:10px">
|
||||
<span class="status-dot ${esc(b.state)}" style="display:inline-block;flex-shrink:0"></span>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="playlist-item-name" style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(s.device_name || d.name || 'Display')}</div>
|
||||
<div class="playlist-item-meta" style="font-size:11px">
|
||||
<span class="wall-panel-liveness"${b.title ? ` title="${esc(b.title)}"` : ''}>${esc(b.label)}</span>${meta ? ` · ${meta}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-sm wall-panel-shot" data-device-id="${esc(s.device_id)}" style="padding:2px 8px;font-size:11px"
|
||||
title="Ask this panel for a screenshot — safe on a live wall, it doesn't change what's playing">Screenshot</button>
|
||||
<a class="btn btn-sm" href="#/device/${esc(s.device_id)}" style="padding:2px 8px;font-size:11px"
|
||||
title="Device info, incident log and remote controls">Open</a>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
<p style="margin:8px 0 0;font-size:10px;color:var(--text-muted);line-height:1.4">
|
||||
Opening a panel doesn't remove it from the wall. Pushing different content to one panel
|
||||
will desync the wall — use the wall playlist above instead.
|
||||
</p>`;
|
||||
|
||||
host.querySelectorAll('.wall-panel-shot').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
requestScreenshot(btn.dataset.deviceId);
|
||||
showToast('Screenshot requested — it appears on the panel\'s device page', 'info');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const panelStatusHandler = (data) => {
|
||||
if (!data?.device_id) return;
|
||||
liveStatus[data.device_id] = data;
|
||||
// Keep the canvas tile dots in step with the list, or the two halves of this screen disagree
|
||||
// about whether a panel is up.
|
||||
const scr = screens.find(s => s.device_id === data.device_id);
|
||||
if (scr && data.status) scr.device_status = data.status;
|
||||
const dot = canvas.querySelector(`.wall-screen[data-device-id="${CSS.escape(data.device_id)}"] .status-dot`);
|
||||
if (dot && data.status) dot.className = `status-dot ${data.status}`;
|
||||
renderPanelStatus();
|
||||
};
|
||||
on('device-status', panelStatusHandler);
|
||||
cleanupHooks.push(() => off('device-status', panelStatusHandler));
|
||||
|
||||
function renderSidebar() {
|
||||
const sidebar = document.getElementById('availableDevices');
|
||||
const unassigned = getUnassigned();
|
||||
|
|
@ -579,7 +741,11 @@ async function renderWallEditor(container, wallId) {
|
|||
if (data.type !== 'sidebar-device' || !data.device_id) return;
|
||||
const vpRect = viewport.getBoundingClientRect();
|
||||
// #14: size the new tile to the device's render resolution (centered on the drop point).
|
||||
const sz = renderSizeFor(data.device_id);
|
||||
// #236: ...as its FOOTPRINT, so a panel already set to portrait drops in as a tall tile that
|
||||
// matches how it is actually hung, instead of a landscape tile the operator has to reason
|
||||
// backwards from.
|
||||
const rotation = defaultRotationFor(data.device_id);
|
||||
const sz = footprintOnWall(data.device_id, rotation);
|
||||
// Drop pixel → canvas-data coord: undo viewport offset, pan, and zoom.
|
||||
const x = (e.clientX - vpRect.left - pan.x) / zoom - sz.w / 2;
|
||||
const y = (e.clientY - vpRect.top - pan.y) / zoom - sz.h / 2;
|
||||
|
|
@ -587,7 +753,7 @@ async function renderWallEditor(container, wallId) {
|
|||
device_id: data.device_id,
|
||||
device_name: data.device_name || 'Display',
|
||||
device_status: data.device_status || 'offline',
|
||||
grid_col: 0, grid_row: 0, rotation: 0,
|
||||
grid_col: 0, grid_row: 0, rotation,
|
||||
x, y, w: sz.w, h: sz.h,
|
||||
});
|
||||
markDirty();
|
||||
|
|
|
|||
187
server/lib/wall-geometry.js
Normal file
187
server/lib/wall-geometry.js
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* Video-wall tile geometry: where does one panel's stage sit inside its own viewport?
|
||||
*
|
||||
* #236. Before per-panel rotation existed, the wall canvas was secretly drawn in FRAMEBUFFER
|
||||
* space, not in the space a person standing in front of the wall sees. That is invisible while
|
||||
* every panel is mounted the normal way up, and actively misleading the moment one isn't: a
|
||||
* customer with two portrait-mounted panels SIDE BY SIDE had to stack them VERTICALLY in the
|
||||
* editor and ship a pre-rotated copy of every video, because the editor was really asking
|
||||
* "where is this panel's 1920x1080 framebuffer?" while showing a picture that read as
|
||||
* "where is this panel on the wall?".
|
||||
*
|
||||
* The model here: the canvas is WALL space — x right, y down, as the audience sees it. A panel
|
||||
* mounted turned occupies a turned rect (a portrait-mounted 1920x1080 panel is a tall tile), and
|
||||
* `rotation` says how far the panel's own image has to be turned to come out upright on the wall.
|
||||
*
|
||||
* rotation is degrees CLOCKWISE that the content is rotated WITHIN the framebuffer — the same
|
||||
* convention as the per-device `orientation` field (lib/orientation-style.js: portrait === 90).
|
||||
* Equivalently: the panel is physically mounted rotated that far ANTI-clockwise. Picking the
|
||||
* opposite sign here would have been just as self-consistent and would have silently disagreed
|
||||
* with the device orientation setting, so it is pinned by test.
|
||||
*
|
||||
* The arithmetic lives here, once, because four players have to agree on it to the pixel — the web
|
||||
* player, Tizen, Android and BrightSign all render the same frame across panels that share a seam.
|
||||
* A half-pixel of disagreement between two of them is a visible line down the middle of the wall.
|
||||
*/
|
||||
|
||||
const VALID_ROTATIONS = [0, 90, 180, 270];
|
||||
|
||||
/**
|
||||
* Coerce whatever the DB / payload carried into a rotation we can render.
|
||||
* Anything unrecognised falls back to 0: a bad value should leave the wall looking exactly as it
|
||||
* was drawn, not turn one panel of a live wall on its side.
|
||||
*/
|
||||
function normalizeWallRotation(value) {
|
||||
const n = Number(value);
|
||||
return VALID_ROTATIONS.includes(n) ? n : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which orientation should the player apply to its container while it is a member of a wall?
|
||||
*
|
||||
* The two settings describe the SAME physical fact (this panel is mounted turned), so applying
|
||||
* both turns the content twice and lands it sideways and off-screen. When the wall carries a
|
||||
* rotation it is authoritative — the tile geometry below already accounts for the mounting — so
|
||||
* the container transform is suppressed. Rotation 0 changes nothing, which is what keeps every
|
||||
* wall that exists today behaving exactly as it does today.
|
||||
*
|
||||
* @param {string} orientation the device's own orientation setting
|
||||
* @param {number} wallRotation per-panel wall rotation (0/90/180/270)
|
||||
* @returns {string} the orientation the player should actually apply
|
||||
*/
|
||||
function orientationForWallMember(orientation, wallRotation) {
|
||||
return normalizeWallRotation(wallRotation) === 0 ? (orientation || 'landscape') : 'landscape';
|
||||
}
|
||||
|
||||
/**
|
||||
* Tile geometry in viewport-relative units.
|
||||
*
|
||||
* The stage is the WHOLE player rect; the viewport crops it to this panel's slice. So the stage is
|
||||
* usually much larger than the screen and usually positioned partly off-view — that is the design,
|
||||
* not a bug.
|
||||
*
|
||||
* Returned as unit-tagged numbers so the same numbers can drive CSS (vw/vh) and Android
|
||||
* (displayMetrics px) without either re-deriving the maths.
|
||||
*
|
||||
* @param {{x:number,y:number,w:number,h:number}} screenRect this panel's rect in wall space
|
||||
* @param {{x:number,y:number,w:number,h:number}} playerRect the content rect in wall space
|
||||
* @param {number} rotation 0/90/180/270
|
||||
* @returns {null|{rotation:number,w:number,wAxis:'x'|'y',h:number,hAxis:'x'|'y',cx:number,cy:number}}
|
||||
* w/h — the stage box BEFORE rotation, as a multiple of the viewport axis named by wAxis/hAxis
|
||||
* ('x' = viewport width, 'y' = viewport height).
|
||||
* cx/cy— where the box's centre goes, as a fraction of viewport width / height.
|
||||
* null when the screen rect has no area (nothing sane to map onto a zero-sized panel).
|
||||
*/
|
||||
function wallStageGeometry(screenRect, playerRect, rotation) {
|
||||
const s = screenRect, p = playerRect;
|
||||
if (!s || !p || !s.w || !s.h) return null;
|
||||
const rot = normalizeWallRotation(rotation);
|
||||
|
||||
// The player rect's centre, as a fraction of this panel's rect. Working from the CENTRE (not the
|
||||
// top-left) is what makes all four rotations one formula: rotation moves a corner but leaves a
|
||||
// centre where it is.
|
||||
const nx = (p.x + p.w / 2 - s.x) / s.w;
|
||||
const ny = (p.y + p.h / 2 - s.y) / s.h;
|
||||
const fw = p.w / s.w; // stage extent along wall X, in units of the panel's wall width
|
||||
const fh = p.h / s.h; // stage extent along wall Y, in units of the panel's wall height
|
||||
|
||||
// A quarter turn swaps which viewport axis each wall axis is measured against: on a panel mounted
|
||||
// sideways, the wall's horizontal is the framebuffer's vertical. Getting this wrong is the classic
|
||||
// "the wall is right but every tile is squashed" symptom.
|
||||
const quarter = rot === 90 || rot === 270;
|
||||
const wAxis = quarter ? 'y' : 'x';
|
||||
const hAxis = quarter ? 'x' : 'y';
|
||||
|
||||
// Where wall-space (nx, ny) lands in framebuffer-normalised (across, down) coordinates.
|
||||
// Derivation: rotating content by `rot` clockwise inside the framebuffer sends the wall's
|
||||
// top-left corner to the framebuffer corner listed, and the wall axes to the framebuffer axes
|
||||
// listed. Each case is pinned by a test.
|
||||
let cx, cy;
|
||||
if (rot === 90) {
|
||||
// wall +X -> framebuffer down, wall +Y -> framebuffer left; wall origin at framebuffer top-right
|
||||
cx = 1 - ny; cy = nx;
|
||||
} else if (rot === 180) {
|
||||
cx = 1 - nx; cy = 1 - ny;
|
||||
} else if (rot === 270) {
|
||||
// wall +X -> framebuffer up, wall +Y -> framebuffer right; wall origin at framebuffer bottom-left
|
||||
cx = ny; cy = 1 - nx;
|
||||
} else {
|
||||
cx = nx; cy = ny;
|
||||
}
|
||||
|
||||
return { rotation: rot, w: fw, wAxis, h: fh, hAxis, cx, cy };
|
||||
}
|
||||
|
||||
/**
|
||||
* The same geometry as CSS values, ready to assign onto element.style.
|
||||
* Empty string means "clear it" — a half-reset leaves a stage stuck at the previous wall's size.
|
||||
*
|
||||
* @returns {{left:string,top:string,width:string,height:string,transform:string,transformOrigin:string}}
|
||||
* or null when the screen rect has no area.
|
||||
*/
|
||||
function wallStageStyle(screenRect, playerRect, rotation) {
|
||||
const s = screenRect, p = playerRect;
|
||||
if (!s || !p || !s.w || !s.h) return null;
|
||||
|
||||
// Unrotated walls take the ORIGINAL top-left expression verbatim, not the centre-based one below.
|
||||
// The two are algebraically equal but not bit-for-bit equal in floating point, and every wall in
|
||||
// the field today is rotation 0. An operator upgrading must not find a hairline seam appear down
|
||||
// a wall that was aligned yesterday, so this path is deliberately left untouched.
|
||||
if (normalizeWallRotation(rotation) === 0) {
|
||||
return {
|
||||
left: (((p.x - s.x) / s.w) * 100) + 'vw',
|
||||
top: (((p.y - s.y) / s.h) * 100) + 'vh',
|
||||
width: ((p.w / s.w) * 100) + 'vw',
|
||||
height: ((p.h / s.h) * 100) + 'vh',
|
||||
transform: '',
|
||||
transformOrigin: '',
|
||||
};
|
||||
}
|
||||
|
||||
const g = wallStageGeometry(s, p, rotation);
|
||||
const unit = (axis) => (axis === 'x' ? 'vw' : 'vh');
|
||||
return {
|
||||
left: (g.cx * 100) + 'vw',
|
||||
top: (g.cy * 100) + 'vh',
|
||||
width: (g.w * 100) + unit(g.wAxis),
|
||||
height: (g.h * 100) + unit(g.hAxis),
|
||||
// translate BEFORE rotate: transform functions apply right-to-left, so the box turns about its
|
||||
// own centre and THEN that centre is moved into place. Reversed, the offset is rotated too and
|
||||
// the tile lands on the wrong side of the panel (the same trap as orientation-style.js).
|
||||
transform: 'translate(-50%, -50%) rotate(' + g.rotation + 'deg)',
|
||||
transformOrigin: 'center center',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The wall footprint of a panel whose framebuffer is renderW x renderH, once mounted at `rotation`.
|
||||
* The editor sizes new tiles with this so a portrait-mounted 1920x1080 panel is drawn as the tall
|
||||
* rect it physically is — which is the whole point of #236.
|
||||
*/
|
||||
function rotatedFootprint(renderW, renderH, rotation) {
|
||||
const rot = normalizeWallRotation(rotation);
|
||||
return (rot === 90 || rot === 270) ? { w: renderH, h: renderW } : { w: renderW, h: renderH };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
VALID_ROTATIONS,
|
||||
normalizeWallRotation,
|
||||
orientationForWallMember,
|
||||
wallStageGeometry,
|
||||
wallStageStyle,
|
||||
rotatedFootprint,
|
||||
};
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.WallGeometry = {
|
||||
VALID_ROTATIONS,
|
||||
normalizeWallRotation,
|
||||
orientationForWallMember,
|
||||
wallStageGeometry,
|
||||
wallStageStyle,
|
||||
rotatedFootprint,
|
||||
};
|
||||
}
|
||||
|
|
@ -241,6 +241,7 @@
|
|||
<script src="/player/schedule-eval.js"></script>
|
||||
<script src="/player/media-mute.js"></script>
|
||||
<script src="/player/orientation-style.js"></script>
|
||||
<script src="/player/wall-geometry.js"></script>
|
||||
<script src="/player/player-media-health.js"></script>
|
||||
<!-- feat/transition-engine: WebGL transition runtime (renderer + shaders). Optional; if it fails to
|
||||
load the player just hard-cuts. Not deferred so it's ready before the first content swap. -->
|
||||
|
|
@ -2250,24 +2251,36 @@
|
|||
// the stage — which keeps the vertical position of every source pixel
|
||||
// identical across devices that share a viewport height (1vh maps to
|
||||
// the same physical pixel on each).
|
||||
// #236: the geometry (including per-panel mounting rotation) comes from the shared rule in
|
||||
// server/lib/wall-geometry.js. Kept out of here because Tizen, Android and this player must
|
||||
// agree to the pixel across a seam, and because the rotated cases are only checkable by test.
|
||||
function styleWallStage(stageEl) {
|
||||
if (!wallConfig?.screen_rect || !wallConfig?.player_rect) return;
|
||||
const s = wallConfig.screen_rect;
|
||||
const p = wallConfig.player_rect;
|
||||
if (!s.w || !s.h) return;
|
||||
const left = ((p.x - s.x) / s.w) * 100;
|
||||
const top = ((p.y - s.y) / s.h) * 100;
|
||||
const width = (p.w / s.w) * 100;
|
||||
const height = (p.h / s.h) * 100;
|
||||
const st = window.WallGeometry
|
||||
? window.WallGeometry.wallStageStyle(s, p, wallConfig.rotation)
|
||||
// If the shared script failed to load, fall back to the unrotated rule rather than leaving
|
||||
// the stage unstyled — a wrong-but-full-frame panel beats a blank one on a live wall.
|
||||
: {
|
||||
left: (((p.x - s.x) / s.w) * 100) + 'vw',
|
||||
top: (((p.y - s.y) / s.h) * 100) + 'vh',
|
||||
width: ((p.w / s.w) * 100) + 'vw',
|
||||
height: ((p.h / s.h) * 100) + 'vh',
|
||||
transform: '', transformOrigin: '',
|
||||
};
|
||||
if (!st) return;
|
||||
const dev = (config.deviceId || '?').slice(0, 8);
|
||||
console.log('[wall/render ' + dev + '] screen_rect: ' + JSON.stringify(s) + ' player_rect: ' + JSON.stringify(p));
|
||||
console.log('[wall/render ' + dev + '] screen_rect: ' + JSON.stringify(s) + ' player_rect: ' + JSON.stringify(p) + ' rotation=' + (wallConfig.rotation || 0));
|
||||
console.log('[wall/render ' + dev + '] viewport: ' + window.innerWidth + 'x' + window.innerHeight + ' DPR=' + window.devicePixelRatio);
|
||||
console.log('[wall/render ' + dev + '] stage: left=' + left.toFixed(4) + 'vw top=' + top.toFixed(4) + 'vh width=' + width.toFixed(4) + 'vw height=' + height.toFixed(4) + 'vh');
|
||||
stageEl.style.left = left + 'vw';
|
||||
stageEl.style.top = top + 'vh';
|
||||
stageEl.style.width = width + 'vw';
|
||||
stageEl.style.height = height + 'vh';
|
||||
stageEl.style.transform = '';
|
||||
console.log('[wall/render ' + dev + '] stage: left=' + st.left + ' top=' + st.top + ' width=' + st.width + ' height=' + st.height + ' transform=' + (st.transform || 'none'));
|
||||
stageEl.style.left = st.left;
|
||||
stageEl.style.top = st.top;
|
||||
stageEl.style.width = st.width;
|
||||
stageEl.style.height = st.height;
|
||||
stageEl.style.transform = st.transform;
|
||||
stageEl.style.transformOrigin = st.transformOrigin;
|
||||
}
|
||||
|
||||
// No-op kept for callers that bind a resize listener (kept around in case
|
||||
|
|
@ -2312,6 +2325,12 @@
|
|||
|
||||
// Apply orientation. #109: the PiP layer gets the SAME transform as the player so a
|
||||
// corner overlay tracks the visible content (not the physical panel) in every orientation.
|
||||
// #236: on a wall panel that carries its own mounting rotation, the two settings describe the
|
||||
// SAME physical fact, so honouring both turns the content twice and throws it off-screen. The
|
||||
// wall rotation wins there; when it is 0 (every wall in the field today) nothing changes.
|
||||
const effectiveOrientation = window.WallGeometry
|
||||
? window.WallGeometry.orientationForWallMember(data.orientation, data.wall_config?.rotation)
|
||||
: data.orientation;
|
||||
if (data.orientation) {
|
||||
// On BrightSign, rotate the OUTPUT first. Video decodes onto a hardware plane the DOM
|
||||
// cannot transform, so the CSS rotation below turns the images and widgets and leaves the
|
||||
|
|
@ -2322,7 +2341,7 @@
|
|||
// cleared or the graphics rotate twice. If it cannot, we fall through to CSS, which rotates
|
||||
// most of the content rather than none of it.
|
||||
if (BS && typeof BS.setOrientation === 'function' && BS.hasHost()) {
|
||||
BS.setOrientation(data.orientation).then((rotatedByHost) => {
|
||||
BS.setOrientation(effectiveOrientation).then((rotatedByHost) => {
|
||||
if (!rotatedByHost) return; // CSS path below already ran
|
||||
[document.getElementById('playerContainer'), document.getElementById('pipContainer')]
|
||||
.forEach((el) => {
|
||||
|
|
@ -2339,7 +2358,7 @@
|
|||
// portrait content landed 420px off-screen on a 1920x1080 panel — rotated correctly and
|
||||
// placed wrongly. Tizen and Android both centre the box first; the web player did not.
|
||||
const st = window.OrientationStyle
|
||||
? window.OrientationStyle.orientationStyle(data.orientation)
|
||||
? window.OrientationStyle.orientationStyle(effectiveOrientation)
|
||||
: null;
|
||||
if (st) {
|
||||
[document.getElementById('playerContainer'), document.getElementById('pipContainer')]
|
||||
|
|
@ -2361,7 +2380,10 @@
|
|||
function wallKey(c) {
|
||||
if (!c) return '';
|
||||
const s = c.screen_rect || {}, p = c.player_rect || {};
|
||||
return `${c.wall_id}:${c.is_leader}:s${s.x},${s.y},${s.w},${s.h}:p${p.x},${p.y},${p.w},${p.h}`;
|
||||
// #236: rotation is part of the key. Left out, re-hanging a panel and changing only its
|
||||
// rotation in the editor looks like "no change" and the panel keeps the old transform until
|
||||
// it is rebooted — which reads as the editor silently ignoring you.
|
||||
return `${c.wall_id}:${c.is_leader}:r${c.rotation || 0}:s${s.x},${s.y},${s.w},${s.h}:p${p.x},${p.y},${p.w},${p.h}`;
|
||||
}
|
||||
const wallChanged = wallKey(wallConfig) !== wallKey(data.wall_config);
|
||||
if (wallChanged) applyWallMode(data.wall_config || null);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ const { db } = require('../db/database');
|
|||
// dead code after the Phase 2.1 role rename (no users carry role='admin'
|
||||
// anymore; team_members is a vestigial table from the pre-workspace model).
|
||||
const { accessContext } = require('../lib/tenancy');
|
||||
// #236: per-panel mounting rotation. Normalised on the way IN as well as out, so a bad value from a
|
||||
// scripted API caller is rejected at the door instead of persisting and confusing every later read.
|
||||
const { normalizeWallRotation } = require('../lib/wall-geometry');
|
||||
|
||||
// Load a wall + access context. Returns the wall row or null after sending
|
||||
// 403/404. requireWrite=true also denies workspace_viewer.
|
||||
|
|
@ -251,7 +254,7 @@ router.put('/:id/devices', requireWallWrite, (req, res) => {
|
|||
for (const d of devices) {
|
||||
insertPos.run(
|
||||
req.params.id, d.device_id,
|
||||
d.grid_col, d.grid_row, d.rotation || 0,
|
||||
d.grid_col, d.grid_row, normalizeWallRotation(d.rotation),
|
||||
d.canvas_x ?? null, d.canvas_y ?? null,
|
||||
d.canvas_width ?? null, d.canvas_height ?? null,
|
||||
);
|
||||
|
|
@ -316,7 +319,7 @@ router.get('/:id/device-config/:deviceId', requireWallRead, (req, res) => {
|
|||
grid_rows: wall.grid_rows,
|
||||
grid_col: position.grid_col,
|
||||
grid_row: position.grid_row,
|
||||
rotation: position.rotation,
|
||||
rotation: normalizeWallRotation(position.rotation),
|
||||
bezel_h_px: wall.bezel_h_mm,
|
||||
bezel_v_px: wall.bezel_v_mm,
|
||||
sync_mode: wall.sync_mode,
|
||||
|
|
|
|||
|
|
@ -410,6 +410,14 @@ app.get('/player/orientation-style.js', (req, res) => {
|
|||
res.sendFile(path.join(__dirname, 'lib', 'orientation-style.js'));
|
||||
});
|
||||
|
||||
// #236: video-wall tile geometry, from its single source. Four players have to agree on this to
|
||||
// the pixel — they render one frame across panels that share a seam, and a half-pixel of
|
||||
// disagreement between two of them is a visible line down the middle of the wall.
|
||||
app.get('/player/wall-geometry.js', (req, res) => {
|
||||
res.type('application/javascript').setHeader('Cache-Control', 'no-cache');
|
||||
res.sendFile(path.join(__dirname, 'lib', 'wall-geometry.js'));
|
||||
});
|
||||
|
||||
app.get('/player/media-mute.js', (req, res) => {
|
||||
res.type('application/javascript').setHeader('Cache-Control', 'no-cache');
|
||||
res.sendFile(path.join(__dirname, 'lib', 'media-mute.js'));
|
||||
|
|
|
|||
358
server/test/wall-geometry.test.js
Normal file
358
server/test/wall-geometry.test.js
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
'use strict';
|
||||
|
||||
// #236: a wall of PORTRAIT panels side by side could not be described as side by side.
|
||||
//
|
||||
// The editor canvas was secretly framebuffer space rather than wall space, so a customer with two
|
||||
// portrait-mounted panels next to each other had to stack them VERTICALLY in the editor and ship a
|
||||
// pre-rotated copy of every video to get correct output. It worked, but only after trial and error,
|
||||
// and it meant portrait walls could never reuse existing content.
|
||||
//
|
||||
// The maths below is the whole fix, so it is pinned here rather than eyeballed on a wall. The tests
|
||||
// are written as one invariant applied to each rotation:
|
||||
//
|
||||
// the panel's viewport must map onto EXACTLY its own rect of wall space
|
||||
//
|
||||
// which is checked by independently re-simulating the CSS box (rotate about centre, then translate)
|
||||
// and inverting it — not by re-running the module's own arithmetic.
|
||||
//
|
||||
// The single most important test in this file is the regression one: every wall in the field today
|
||||
// is rotation 0, and an operator who upgrades must not find a wall that was aligned yesterday has
|
||||
// moved. That one asserts byte-identical output against the pre-#236 expression.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
normalizeWallRotation,
|
||||
orientationForWallMember,
|
||||
wallStageGeometry,
|
||||
wallStageStyle,
|
||||
rotatedFootprint,
|
||||
} = require('../lib/wall-geometry');
|
||||
const { ROTATION_DEG } = require('../lib/orientation-style');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// An independent simulation of what the browser will actually do with the styles.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function toPx(value, vw, vh) {
|
||||
const m = /^(-?[\d.]+)(vw|vh|%)$/.exec(value);
|
||||
assert.ok(m, `unhandled CSS length: ${value}`);
|
||||
const n = Number(m[1]) / 100;
|
||||
if (m[2] === 'vw') return n * vw;
|
||||
if (m[2] === 'vh') return n * vh;
|
||||
return n; // % handled by the caller
|
||||
}
|
||||
|
||||
/**
|
||||
* Where does a wall-space point end up in the panel's framebuffer, given these styles?
|
||||
* The stage box's own local coordinates span the PLAYER rect, so wall -> local is a plain
|
||||
* proportional map, and local -> framebuffer is the CSS transform re-implemented by hand.
|
||||
*/
|
||||
function makeMapper(style, playerRect, vw, vh) {
|
||||
const bw = toPx(style.width, vw, vh);
|
||||
const bh = toPx(style.height, vw, vh);
|
||||
const left = toPx(style.left, vw, vh);
|
||||
const top = toPx(style.top, vw, vh);
|
||||
|
||||
const hasTranslate = /translate\(-50%, -50%\)/.test(style.transform || '');
|
||||
// Without the translate, left/top position the box's TOP-LEFT; with it, its centre.
|
||||
const cx = hasTranslate ? left : left + bw / 2;
|
||||
const cy = hasTranslate ? top : top + bh / 2;
|
||||
|
||||
const rotM = /rotate\((-?\d+)deg\)/.exec(style.transform || '');
|
||||
const deg = rotM ? Number(rotM[1]) : 0;
|
||||
const rad = (deg * Math.PI) / 180;
|
||||
const cos = Math.cos(rad), sin = Math.sin(rad);
|
||||
|
||||
// CSS rotate() in a y-down space: (x,y) -> (x cos - y sin, x sin + y cos)
|
||||
const fwd = (lx, ly) => {
|
||||
const dx = lx - bw / 2, dy = ly - bh / 2;
|
||||
return { x: cx + dx * cos - dy * sin, y: cy + dx * sin + dy * cos };
|
||||
};
|
||||
const inv = (fx, fy) => {
|
||||
const dx = fx - cx, dy = fy - cy;
|
||||
return { x: bw / 2 + dx * cos + dy * sin, y: bh / 2 - dx * sin + dy * cos };
|
||||
};
|
||||
|
||||
return {
|
||||
// wall-space point -> framebuffer pixel
|
||||
wallToFb(wx, wy) {
|
||||
const lx = ((wx - playerRect.x) / playerRect.w) * bw;
|
||||
const ly = ((wy - playerRect.y) / playerRect.h) * bh;
|
||||
return fwd(lx, ly);
|
||||
},
|
||||
// framebuffer pixel -> wall-space point
|
||||
fbToWall(fx, fy) {
|
||||
const l = inv(fx, fy);
|
||||
return {
|
||||
x: playerRect.x + (l.x / bw) * playerRect.w,
|
||||
y: playerRect.y + (l.y / bh) * playerRect.h,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The rect of wall space that this panel's viewport actually shows. */
|
||||
function visibleWallRect(style, playerRect, vw, vh) {
|
||||
const m = makeMapper(style, playerRect, vw, vh);
|
||||
const corners = [m.fbToWall(0, 0), m.fbToWall(vw, 0), m.fbToWall(0, vh), m.fbToWall(vw, vh)];
|
||||
const xs = corners.map(c => c.x), ys = corners.map(c => c.y);
|
||||
return {
|
||||
x: Math.min(...xs), y: Math.min(...ys),
|
||||
w: Math.max(...xs) - Math.min(...xs),
|
||||
h: Math.max(...ys) - Math.min(...ys),
|
||||
};
|
||||
}
|
||||
|
||||
function assertRectClose(actual, expected, msg) {
|
||||
for (const k of ['x', 'y', 'w', 'h']) {
|
||||
assert.ok(Math.abs(actual[k] - expected[k]) < 1e-6,
|
||||
`${msg}: ${k} was ${actual[k]}, expected ${expected[k]}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// THE REGRESSION TEST — existing landscape walls must not move.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The expression exactly as the web player computed it before #236 existed.
|
||||
function legacyStyle(s, p) {
|
||||
return {
|
||||
left: (((p.x - s.x) / s.w) * 100) + 'vw',
|
||||
top: (((p.y - s.y) / s.h) * 100) + 'vh',
|
||||
width: ((p.w / s.w) * 100) + 'vw',
|
||||
height: ((p.h / s.h) * 100) + 'vh',
|
||||
transform: '',
|
||||
transformOrigin: '',
|
||||
};
|
||||
}
|
||||
|
||||
test('REGRESSION: a landscape 2x2 wall produces byte-identical styles to before #236', () => {
|
||||
// Four 320x180 tiles, player fitted to the bounding box — the shape Auto-arrange produces.
|
||||
const player = { x: 0, y: 0, w: 640, h: 360 };
|
||||
const tiles = [
|
||||
{ x: 0, y: 0, w: 320, h: 180 },
|
||||
{ x: 320, y: 0, w: 320, h: 180 },
|
||||
{ x: 0, y: 180, w: 320, h: 180 },
|
||||
{ x: 320, y: 180, w: 320, h: 180 },
|
||||
];
|
||||
for (const s of tiles) {
|
||||
// rotation absent, 0, null and a junk value must all land on the untouched path — a wall row
|
||||
// written before the column meant anything must not be re-laid-out by an upgrade.
|
||||
for (const rot of [undefined, 0, null, 'nonsense']) {
|
||||
assert.deepEqual(wallStageStyle(s, player, rot), legacyStyle(s, player),
|
||||
`tile ${JSON.stringify(s)} rotation=${rot}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('REGRESSION: a bezelled, offset, non-integer landscape wall is also untouched', () => {
|
||||
// Free-form drags produce awkward numbers; those are exactly where float drift would show up as a
|
||||
// hairline seam, so the untouched path has to cover them too.
|
||||
const player = { x: -37, y: 12, w: 1993, h: 1121 };
|
||||
const s = { x: 993, y: 12, w: 1000, h: 563 };
|
||||
assert.deepEqual(wallStageStyle(s, player, 0), legacyStyle(s, player));
|
||||
});
|
||||
|
||||
test('the unrotated centre-based geometry agrees with the legacy top-left expression', () => {
|
||||
// The fast path above is a deliberate duplicate; this proves it is a duplicate and not a fork.
|
||||
const player = { x: 0, y: 0, w: 640, h: 360 };
|
||||
const s = { x: 320, y: 0, w: 320, h: 180 };
|
||||
const g = wallStageGeometry(s, player, 0);
|
||||
assert.equal((g.cx - g.w / 2) * 100, -100, 'left');
|
||||
assert.equal((g.cy - g.h / 2) * 100, 0, 'top');
|
||||
assert.equal(g.w * 100, 200, 'width');
|
||||
assert.equal(g.h * 100, 200, 'height');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// THE BUG: two portrait panels, side by side, described as side by side.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('THE BUG: two portrait-mounted panels side by side each show their own half', () => {
|
||||
// Two 1920x1080 panels turned on their side and hung next to each other. In wall space each is a
|
||||
// 1080-wide, 1920-tall rect; the player spans both. Before #236 this arrangement was impossible
|
||||
// to express — the operator had to stack them vertically and pre-rotate the video.
|
||||
const VW = 1920, VH = 1080; // the framebuffer is still landscape; the PANEL is turned
|
||||
const left = { x: 0, y: 0, w: 1080, h: 1920 };
|
||||
const right = { x: 1080, y: 0, w: 1080, h: 1920 };
|
||||
const player = { x: 0, y: 0, w: 2160, h: 1920 };
|
||||
|
||||
const leftStyle = wallStageStyle(left, player, 90);
|
||||
const rightStyle = wallStageStyle(right, player, 90);
|
||||
|
||||
assertRectClose(visibleWallRect(leftStyle, player, VW, VH), left,
|
||||
'left panel must show the LEFT half of the wall');
|
||||
assertRectClose(visibleWallRect(rightStyle, player, VW, VH), right,
|
||||
'right panel must show the RIGHT half of the wall');
|
||||
|
||||
// ...and nothing is mirrored: the wall's top-left corner has to appear at the framebuffer's
|
||||
// top-right on a panel turned this way. A sign error here shows a perfect mirror image, which
|
||||
// reads as "the panels are in the wrong order" and sends you back to the editor.
|
||||
const m = makeMapper(leftStyle, player, VW, VH);
|
||||
const topLeft = m.wallToFb(0, 0);
|
||||
assert.ok(Math.abs(topLeft.x - VW) < 1e-6, `wall top-left x: ${topLeft.x}`);
|
||||
assert.ok(Math.abs(topLeft.y - 0) < 1e-6, `wall top-left y: ${topLeft.y}`);
|
||||
});
|
||||
|
||||
test('a portrait wall needs no pre-rotated content: wall +X runs down the framebuffer', () => {
|
||||
// The customer had to rotate every source video so its top faced left. That is only necessary if
|
||||
// the renderer ignores the mounting; with rotation applied, wall-right maps to framebuffer-down
|
||||
// and unmodified landscape content comes out upright across the panels.
|
||||
const player = { x: 0, y: 0, w: 2160, h: 1920 };
|
||||
const s = { x: 0, y: 0, w: 1080, h: 1920 };
|
||||
const m = makeMapper(wallStageStyle(s, player, 90), player, 1920, 1080);
|
||||
const o = m.wallToFb(0, 0);
|
||||
const right = m.wallToFb(100, 0); // move RIGHT along the wall
|
||||
const down = m.wallToFb(0, 100); // move DOWN the wall
|
||||
assert.ok(right.y > o.y && Math.abs(right.x - o.x) < 1e-6, 'wall +X must be framebuffer +down');
|
||||
assert.ok(down.x < o.x && Math.abs(down.y - o.y) < 1e-6, 'wall +Y must be framebuffer +left');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Every rotation, and mixtures of them.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('every rotation maps the viewport onto exactly the panel\'s own rect of wall space', () => {
|
||||
const player = { x: -50, y: -20, w: 1400, h: 900 };
|
||||
const s = { x: 100, y: 60, w: 400, h: 300 };
|
||||
for (const rot of [0, 90, 180, 270]) {
|
||||
// A turned panel's framebuffer is landscape while its wall rect is portrait, so the viewport
|
||||
// used here is the framebuffer's, not the tile's.
|
||||
const vw = (rot === 90 || rot === 270) ? s.h : s.w;
|
||||
const vh = (rot === 90 || rot === 270) ? s.w : s.h;
|
||||
assertRectClose(visibleWallRect(wallStageStyle(s, player, rot), player, vw, vh), s,
|
||||
`rotation ${rot}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('180 turns the content over without swapping the box dimensions', () => {
|
||||
// Giving a half-turn the quarter-turn treatment swaps width and height for no reason and squashes
|
||||
// the tile — the "wall is right but every panel is stretched" symptom.
|
||||
const g = wallStageGeometry({ x: 0, y: 0, w: 320, h: 180 }, { x: 0, y: 0, w: 640, h: 360 }, 180);
|
||||
assert.equal(g.wAxis, 'x');
|
||||
assert.equal(g.hAxis, 'y');
|
||||
const s = wallStageStyle({ x: 0, y: 0, w: 320, h: 180 }, { x: 0, y: 0, w: 640, h: 360 }, 180);
|
||||
assert.match(s.width, /vw$/);
|
||||
assert.match(s.height, /vh$/);
|
||||
});
|
||||
|
||||
test('a quarter turn measures wall-horizontal against the framebuffer VERTICAL', () => {
|
||||
const g = wallStageGeometry({ x: 0, y: 0, w: 1080, h: 1920 }, { x: 0, y: 0, w: 2160, h: 1920 }, 90);
|
||||
assert.equal(g.wAxis, 'y', 'wall width is measured down the framebuffer on a turned panel');
|
||||
assert.equal(g.hAxis, 'x');
|
||||
const s = wallStageStyle({ x: 0, y: 0, w: 1080, h: 1920 }, { x: 0, y: 0, w: 2160, h: 1920 }, 90);
|
||||
assert.match(s.width, /vh$/, 'width in vh, or the tile is sized against the wrong axis');
|
||||
assert.match(s.height, /vw$/);
|
||||
});
|
||||
|
||||
test('a MIXED wall works: one landscape panel beside two stacked portrait ones', () => {
|
||||
// Real installs are not homogeneous. A landscape 1920x1080 on the left, and to its right two
|
||||
// portrait-mounted panels stacked — one turned each way, which is what happens when an installer
|
||||
// hangs the top one upside down to keep the cable runs short.
|
||||
const player = { x: 0, y: 0, w: 3000, h: 1080 };
|
||||
const cases = [
|
||||
{ rect: { x: 0, y: 0, w: 1920, h: 1080 }, rot: 0, vw: 1920, vh: 1080 },
|
||||
{ rect: { x: 1920, y: 0, w: 540, h: 540 }, rot: 90, vw: 540, vh: 540 },
|
||||
{ rect: { x: 2460, y: 0, w: 540, h: 1080 }, rot: 270, vw: 1080, vh: 540 },
|
||||
];
|
||||
for (const c of cases) {
|
||||
assertRectClose(visibleWallRect(wallStageStyle(c.rect, player, c.rot), player, c.vw, c.vh),
|
||||
c.rect, `mixed wall tile rotation ${c.rot}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a tile that only partly overlaps the player still maps its own rect', () => {
|
||||
// "Fit to player" is optional — operators deliberately leave a panel hanging off the content so
|
||||
// it shows black. The mapping must still be the panel's rect, not the overlap.
|
||||
const player = { x: 0, y: 0, w: 1000, h: 600 };
|
||||
const s = { x: 800, y: 400, w: 400, h: 400 };
|
||||
assertRectClose(visibleWallRect(wallStageStyle(s, player, 90), player, 400, 400), s, 'overhang');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conventions and guards.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('wall rotation uses the SAME sign convention as the device orientation setting', () => {
|
||||
// Two settings that both mean "this panel is mounted turned" but disagree on which way is 90
|
||||
// would be a permanent source of upside-down walls.
|
||||
assert.equal(ROTATION_DEG.portrait, 90);
|
||||
assert.equal(ROTATION_DEG['landscape-flipped'], 180);
|
||||
assert.equal(ROTATION_DEG['portrait-flipped'], 270);
|
||||
const s = wallStageStyle({ x: 0, y: 0, w: 1080, h: 1920 }, { x: 0, y: 0, w: 1080, h: 1920 }, 90);
|
||||
assert.match(s.transform, /rotate\(90deg\)$/);
|
||||
});
|
||||
|
||||
test('the translate comes BEFORE the rotate, or the offset is rotated too', () => {
|
||||
const s = wallStageStyle({ x: 0, y: 0, w: 1080, h: 1920 }, { x: 0, y: 0, w: 2160, h: 1920 }, 270);
|
||||
assert.equal(s.transform, 'translate(-50%, -50%) rotate(270deg)');
|
||||
});
|
||||
|
||||
test('an unrecognised rotation falls back to 0 rather than turning a live panel sideways', () => {
|
||||
for (const bad of [45, -90, '90deg', NaN, undefined, null, {}, 360]) {
|
||||
assert.equal(normalizeWallRotation(bad), 0, String(bad));
|
||||
}
|
||||
for (const good of [0, 90, 180, 270, '90', '270']) {
|
||||
assert.equal(normalizeWallRotation(good), Number(good));
|
||||
}
|
||||
});
|
||||
|
||||
test('a zero-sized screen rect yields null instead of dividing by zero', () => {
|
||||
// A wall row saved with canvas_width 0 would otherwise produce Infinity styles and a blank panel.
|
||||
const p = { x: 0, y: 0, w: 100, h: 100 };
|
||||
assert.equal(wallStageStyle({ x: 0, y: 0, w: 0, h: 100 }, p, 0), null);
|
||||
assert.equal(wallStageStyle({ x: 0, y: 0, w: 100, h: 0 }, p, 90), null);
|
||||
assert.equal(wallStageGeometry({ x: 0, y: 0, w: 0, h: 0 }, p, 90), null);
|
||||
assert.equal(wallStageStyle(null, p, 0), null);
|
||||
});
|
||||
|
||||
test('wall rotation suppresses the device orientation transform, but only when set', () => {
|
||||
// Both settings describe the same physical fact; applying both turns the content twice.
|
||||
assert.equal(orientationForWallMember('portrait', 90), 'landscape');
|
||||
assert.equal(orientationForWallMember('portrait-flipped', 270), 'landscape');
|
||||
// Rotation 0 must change nothing at all — that is what keeps today's walls behaving as today.
|
||||
assert.equal(orientationForWallMember('portrait', 0), 'portrait');
|
||||
assert.equal(orientationForWallMember('landscape-flipped', undefined), 'landscape-flipped');
|
||||
assert.equal(orientationForWallMember(undefined, 0), 'landscape');
|
||||
});
|
||||
|
||||
test('the Tizen player\'s hand-ported copy of this rule still agrees with it', () => {
|
||||
// The .wgt is packaged and cannot load the shared script the web player pulls, so Tizen carries a
|
||||
// hand-written copy. Two players disagreeing by a pixel is a visible line down the middle of a
|
||||
// wall, and nothing else in the build would notice the drift — so the copy is executed here
|
||||
// against the canonical rule rather than trusted.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'tizen', 'js', 'player.js'), 'utf8');
|
||||
const m = /WallController\.prototype\.styleStage = function \(config\) \{[\s\S]*?\n\};/.exec(src);
|
||||
assert.ok(m, 'could not find WallController.prototype.styleStage in the Tizen player');
|
||||
|
||||
const WallController = { prototype: {} };
|
||||
// eslint-disable-next-line no-new-func
|
||||
new Function('WallController', m[0])(WallController);
|
||||
|
||||
const cases = [
|
||||
{ s: { x: 0, y: 0, w: 320, h: 180 }, p: { x: 0, y: 0, w: 640, h: 360 }, rot: 0 },
|
||||
{ s: { x: 320, y: 180, w: 320, h: 180 }, p: { x: 0, y: 0, w: 640, h: 360 }, rot: 0 },
|
||||
{ s: { x: 1080, y: 0, w: 1080, h: 1920 }, p: { x: 0, y: 0, w: 2160, h: 1920 }, rot: 90 },
|
||||
{ s: { x: 0, y: 0, w: 400, h: 300 }, p: { x: -50, y: -20, w: 1400, h: 900 }, rot: 180 },
|
||||
{ s: { x: 2460, y: 0, w: 540, h: 1080 }, p: { x: 0, y: 0, w: 3000, h: 1080 }, rot: 270 },
|
||||
{ s: { x: 0, y: 0, w: 320, h: 180 }, p: { x: 0, y: 0, w: 640, h: 360 }, rot: 45 }, // junk -> 0
|
||||
];
|
||||
for (const c of cases) {
|
||||
const ctx = { stage: { classList: { add() {}, remove() {} }, style: {} } };
|
||||
WallController.prototype.styleStage.call(ctx, { screen_rect: c.s, player_rect: c.p, rotation: c.rot });
|
||||
const want = wallStageStyle(c.s, c.p, c.rot);
|
||||
for (const k of ['left', 'top', 'width', 'height', 'transform', 'transformOrigin']) {
|
||||
assert.equal(ctx.stage.style[k], want[k], `rotation ${c.rot}: ${k}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a turned panel\'s wall footprint is its framebuffer with the sides swapped', () => {
|
||||
assert.deepEqual(rotatedFootprint(1920, 1080, 0), { w: 1920, h: 1080 });
|
||||
assert.deepEqual(rotatedFootprint(1920, 1080, 90), { w: 1080, h: 1920 });
|
||||
assert.deepEqual(rotatedFootprint(1920, 1080, 180), { w: 1920, h: 1080 });
|
||||
assert.deepEqual(rotatedFootprint(1920, 1080, 270), { w: 1080, h: 1920 });
|
||||
});
|
||||
101
server/test/wall-payload.test.js
Normal file
101
server/test/wall-payload.test.js
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
'use strict';
|
||||
|
||||
// #236: what the SERVER hands a wall panel.
|
||||
//
|
||||
// wall-geometry.test.js pins the maths; this pins the wiring — that a wall row saved before
|
||||
// per-panel rotation existed still produces exactly the rectangles it produced before, and that a
|
||||
// rotation which somehow got into the column can never reach a player as anything but 0/90/180/270.
|
||||
// The failure this guards against is the worst kind for this feature: an operator upgrades and one
|
||||
// panel of a working wall comes back on its side.
|
||||
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
process.env.DATA_DIR = path.join(os.tmpdir(), 'st-wallpayload-' + crypto.randomBytes(4).toString('hex'));
|
||||
process.env.SELF_HOSTED = 'true';
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
const { test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const http = require('node:http');
|
||||
const { Server } = require('socket.io');
|
||||
const { db } = require('../db/database');
|
||||
const setupDeviceSocket = require('../ws/deviceSocket');
|
||||
|
||||
let httpServer, io, buildPlaylistPayload;
|
||||
|
||||
function addWall(id, rows) {
|
||||
db.prepare(`INSERT INTO video_walls (id, user_id, name, grid_cols, grid_rows, bezel_h_mm, bezel_v_mm, leader_device_id)
|
||||
VALUES (?, 'u', ?, 2, 1, 0, 0, ?)`).run(id, id, rows[0].device_id);
|
||||
for (const r of rows) {
|
||||
db.prepare(`INSERT INTO devices (id, status, wall_id, orientation) VALUES (?, 'online', ?, ?)`)
|
||||
.run(r.device_id, id, r.orientation || 'landscape');
|
||||
db.prepare(`INSERT INTO video_wall_devices
|
||||
(wall_id, device_id, grid_col, grid_row, rotation, canvas_x, canvas_y, canvas_width, canvas_height)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(id, r.device_id, r.col, 0, r.rotation, r.x ?? null, r.y ?? null, r.w ?? null, r.h ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
httpServer = http.createServer(); io = new Server(httpServer); setupDeviceSocket(io);
|
||||
buildPlaylistPayload = setupDeviceSocket.buildPlaylistPayload;
|
||||
await new Promise((r) => httpServer.listen(0, r));
|
||||
|
||||
db.pragma('foreign_keys = OFF');
|
||||
// A wall as it would have been saved before #236: no canvas_* columns at all, rotation 0. The
|
||||
// renderer has to derive the rects from grid position and the historic 320x180 base.
|
||||
addWall('legacy', [
|
||||
{ device_id: 'legacy-a', col: 0, rotation: 0 },
|
||||
{ device_id: 'legacy-b', col: 1, rotation: 0 },
|
||||
]);
|
||||
// A wall drawn in the new editor: two portrait-mounted panels genuinely side by side.
|
||||
addWall('portrait', [
|
||||
{ device_id: 'port-a', col: 0, rotation: 90, x: 0, y: 0, w: 1080, h: 1920, orientation: 'portrait' },
|
||||
{ device_id: 'port-b', col: 1, rotation: 90, x: 1080, y: 0, w: 1080, h: 1920, orientation: 'portrait' },
|
||||
]);
|
||||
// A rotation value nothing in the product can produce — a hand-run UPDATE, a bad import.
|
||||
addWall('junk', [
|
||||
{ device_id: 'junk-a', col: 0, rotation: 45, x: 0, y: 0, w: 320, h: 180 },
|
||||
{ device_id: 'junk-b', col: 1, rotation: 0, x: 320, y: 0, w: 320, h: 180 },
|
||||
]);
|
||||
db.pragma('foreign_keys = ON');
|
||||
});
|
||||
after(() => { try { io.close(); } catch { /* */ } try { httpServer.close(); } catch { /* */ } });
|
||||
|
||||
test('REGRESSION: a pre-#236 wall row still produces the rectangles it always did', () => {
|
||||
// Grid-derived from the historic 320x180 base, player rect = bounding box of both tiles.
|
||||
const a = buildPlaylistPayload('legacy-a').wall_config;
|
||||
const b = buildPlaylistPayload('legacy-b').wall_config;
|
||||
assert.deepEqual(a.screen_rect, { x: 0, y: 0, w: 320, h: 180 });
|
||||
assert.deepEqual(b.screen_rect, { x: 320, y: 0, w: 320, h: 180 });
|
||||
assert.deepEqual(a.player_rect, { x: 0, y: 0, w: 640, h: 180 });
|
||||
assert.deepEqual(b.player_rect, a.player_rect, 'every panel gets the SAME player rect');
|
||||
assert.equal(a.rotation, 0, 'an untouched wall must stay unrotated');
|
||||
assert.equal(b.rotation, 0);
|
||||
});
|
||||
|
||||
test('a portrait wall reaches the player as side-by-side tiles plus a rotation', () => {
|
||||
// The whole point of #236: the arrangement in the payload matches the physical arrangement,
|
||||
// instead of being transposed by the operator to compensate for the renderer.
|
||||
const a = buildPlaylistPayload('port-a').wall_config;
|
||||
const b = buildPlaylistPayload('port-b').wall_config;
|
||||
assert.deepEqual(a.screen_rect, { x: 0, y: 0, w: 1080, h: 1920 });
|
||||
assert.deepEqual(b.screen_rect, { x: 1080, y: 0, w: 1080, h: 1920 }, 'side by side, not stacked');
|
||||
assert.deepEqual(a.player_rect, { x: 0, y: 0, w: 2160, h: 1920 });
|
||||
assert.equal(a.rotation, 90);
|
||||
assert.equal(b.rotation, 90);
|
||||
});
|
||||
|
||||
test('a junk rotation in the column degrades to 0 rather than reaching a live panel', () => {
|
||||
// "As drawn" is a recoverable wrong; one panel of a working wall lying on its side is not.
|
||||
assert.equal(buildPlaylistPayload('junk-a').wall_config.rotation, 0);
|
||||
assert.equal(buildPlaylistPayload('junk-b').wall_config.rotation, 0);
|
||||
});
|
||||
|
||||
test('a device outside any wall still gets wall_config: null', () => {
|
||||
db.pragma('foreign_keys = OFF');
|
||||
db.prepare("INSERT INTO devices (id, status) VALUES ('solo', 'online')").run();
|
||||
db.pragma('foreign_keys = ON');
|
||||
assert.equal(buildPlaylistPayload('solo').wall_config, null);
|
||||
});
|
||||
|
|
@ -12,6 +12,7 @@ const reconnectThrottle = require('../lib/reconnect-throttle');
|
|||
const contentAckLimiter = require('../lib/content-ack-limiter');
|
||||
const statusLogWriter = require('../lib/status-log-writer');
|
||||
const { normalizeTransitions } = require('../lib/transition-config');
|
||||
const { normalizeWallRotation } = require('../lib/wall-geometry'); // #236 per-panel mounting rotation
|
||||
const { protectSocket } = require('../lib/safe-socket');
|
||||
const flapLimiter = require('../lib/flap-limiter');
|
||||
const sessionSettle = require('../lib/session-settle'); // #148 patch2: eviction-storm debounce
|
||||
|
|
@ -421,7 +422,10 @@ function buildPlaylistPayload(deviceId) {
|
|||
screen_rect: screenRect,
|
||||
player_rect: playerRect,
|
||||
is_leader: wall.leader_device_id === deviceId,
|
||||
rotation: pos.rotation || 0,
|
||||
// #236: how far this panel's own image has to be turned to come out upright on the wall.
|
||||
// Normalised here so a junk column value can never reach a player and stand one panel of a
|
||||
// live wall on its side — a bad rotation degrades to "as drawn", not to "sideways".
|
||||
rotation: normalizeWallRotation(pos.rotation),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1203,11 +1203,38 @@ WallController.prototype.styleStage = function (config) {
|
|||
this.stage.classList.add('wall-mode');
|
||||
var st = this.stage.style;
|
||||
st.position = 'absolute';
|
||||
st.left = (((p.x - s.x) / s.w) * 100) + 'vw';
|
||||
st.top = (((p.y - s.y) / s.h) * 100) + 'vh';
|
||||
st.width = ((p.w / s.w) * 100) + 'vw';
|
||||
st.height = ((p.h / s.h) * 100) + 'vh';
|
||||
st.transform = ''; st.transformOrigin = '';
|
||||
|
||||
// #236: per-panel mounting rotation. Ported by hand from server/lib/wall-geometry.js, which is the
|
||||
// canonical rule and the only place it is tested — the .wgt is packaged, so it cannot pull the
|
||||
// shared script the web player loads. Any change there has to be mirrored here or a mixed wall
|
||||
// grows a seam. rotation is degrees CLOCKWISE the content is turned inside the framebuffer, the
|
||||
// same convention as the device orientation setting.
|
||||
var rot = [0, 90, 180, 270].indexOf(Number(config.rotation)) >= 0 ? Number(config.rotation) : 0;
|
||||
if (rot === 0) {
|
||||
// Left byte-identical to the pre-#236 expression on purpose: every wall in the field is
|
||||
// rotation 0 and must not shift by a float's worth after an update.
|
||||
st.left = (((p.x - s.x) / s.w) * 100) + 'vw';
|
||||
st.top = (((p.y - s.y) / s.h) * 100) + 'vh';
|
||||
st.width = ((p.w / s.w) * 100) + 'vw';
|
||||
st.height = ((p.h / s.h) * 100) + 'vh';
|
||||
st.transform = ''; st.transformOrigin = '';
|
||||
return;
|
||||
}
|
||||
var nx = (p.x + p.w / 2 - s.x) / s.w;
|
||||
var ny = (p.y + p.h / 2 - s.y) / s.h;
|
||||
var quarter = (rot === 90 || rot === 270);
|
||||
var cx, cy;
|
||||
if (rot === 90) { cx = 1 - ny; cy = nx; }
|
||||
else if (rot === 180) { cx = 1 - nx; cy = 1 - ny; }
|
||||
else { cx = ny; cy = 1 - nx; }
|
||||
st.left = (cx * 100) + 'vw';
|
||||
st.top = (cy * 100) + 'vh';
|
||||
// A quarter turn measures the wall's horizontal against the framebuffer's VERTICAL.
|
||||
st.width = ((p.w / s.w) * 100) + (quarter ? 'vh' : 'vw');
|
||||
st.height = ((p.h / s.h) * 100) + (quarter ? 'vw' : 'vh');
|
||||
// translate BEFORE rotate, or the -50% offset is rotated too and the tile lands on the wrong side.
|
||||
st.transform = 'translate(-50%, -50%) rotate(' + rot + 'deg)';
|
||||
st.transformOrigin = 'center center';
|
||||
};
|
||||
|
||||
// #group-sync: the sync id is wall_id (WALL) or group_id (GROUP mode).
|
||||
|
|
|
|||
Loading…
Reference in a new issue