mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
#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.
359 lines
17 KiB
JavaScript
359 lines
17 KiB
JavaScript
'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 });
|
|
});
|