diff --git a/CHANGELOG.md b/CHANGELOG.md index 6db2594..96034d7 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt index 98e4399..3a6893e 100644 --- a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt +++ b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt @@ -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() { diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 097da3e..9eb8e98 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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 } diff --git a/frontend/js/views/dashboard.js b/frontend/js/views/dashboard.js index e79ecfc..1382888 100644 --- a/frontend/js/views/dashboard.js +++ b/frontend/js/views/dashboard.js @@ -174,7 +174,9 @@ function renderWallCard(wall) { cells.push(`
`); } } - 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 `+ Lay the canvas out to match the physical 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. +
+Arrow keys nudge by 1px. Hold Shift for 10px. Click outside any rect to deselect.
No panels on this wall yet — drag displays onto the canvas.
`; + 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 = ` ++ 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. +
`; + + 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(); diff --git a/server/lib/wall-geometry.js b/server/lib/wall-geometry.js new file mode 100644 index 0000000..89ed7bf --- /dev/null +++ b/server/lib/wall-geometry.js @@ -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, + }; +} diff --git a/server/player/index.html b/server/player/index.html index 3e3031c..e24ae7a 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -241,6 +241,7 @@ + @@ -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); diff --git a/server/routes/video-walls.js b/server/routes/video-walls.js index 55041ac..4380dd8 100644 --- a/server/routes/video-walls.js +++ b/server/routes/video-walls.js @@ -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, diff --git a/server/server.js b/server/server.js index 4f550fb..ce3d6ab 100644 --- a/server/server.js +++ b/server/server.js @@ -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')); diff --git a/server/test/wall-geometry.test.js b/server/test/wall-geometry.test.js new file mode 100644 index 0000000..6e159d3 --- /dev/null +++ b/server/test/wall-geometry.test.js @@ -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 }); +}); diff --git a/server/test/wall-payload.test.js b/server/test/wall-payload.test.js new file mode 100644 index 0000000..c35428e --- /dev/null +++ b/server/test/wall-payload.test.js @@ -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); +}); diff --git a/server/ws/deviceSocket.js b/server/ws/deviceSocket.js index 903c25a..6c66e65 100644 --- a/server/ws/deviceSocket.js +++ b/server/ws/deviceSocket.js @@ -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), }; } } diff --git a/tizen/js/player.js b/tizen/js/player.js index 0b34b8e..55ff953 100644 --- a/tizen/js/player.js +++ b/tizen/js/player.js @@ -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).