Skip to content

Commit 7b64d2b

Browse files
authored
Merge pull request #738 from deveshbervar/screenshot-tiling-fix
feat(screenshot): Add tiled rendering to support very large screenshots (#581)
2 parents 12fba53 + 56317f2 commit 7b64d2b

File tree

3 files changed

+187
-118
lines changed

3 files changed

+187
-118
lines changed

Dockerfile

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,62 @@
1-
FROM nvidia/opengl:1.0-glvnd-devel-ubuntu20.04 AS build
1+
FROM nvidia/opengl:1.0-glvnd-devel-ubuntu22.04 AS build
22

3-
# 1. System dependencies needed by native Node modules (canvas, headless-gl)
4-
RUN apt-get update -y
5-
RUN apt-get install -y curl gnupg ca-certificates && \
6-
curl -sL https://deb.nodesource.com/setup_18.x | bash - && \
7-
apt-get install -y nodejs
8-
RUN apt-get install -y build-essential python libxi-dev libglu-dev libglew-dev pkg-config git
3+
# 0. Set frontend to noninteractive to suppress warnings during install
94
ARG DEBIAN_FRONTEND=noninteractive
10-
RUN apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev
115

12-
# 2. Project workspace
6+
# 1. System dependencies & Python Fix
7+
# We combine everything into ONE Run command to keep the image small and avoid errors.
8+
RUN apt-get update -y && \
9+
# Install prerequisites for Node setup
10+
apt-get install -y --no-install-recommends curl gnupg ca-certificates && \
11+
# Setup Node 18 repository
12+
curl -sL https://deb.nodesource.com/setup_18.x | bash - && \
13+
# Install Node, Python 3, Build Tools, and Canvas dependencies
14+
apt-get install -y --no-install-recommends \
15+
nodejs \
16+
build-essential \
17+
python3 \
18+
python3-dev \
19+
python-is-python3 \
20+
libxi-dev \
21+
libglu-dev \
22+
libglew-dev \
23+
pkg-config \
24+
git \
25+
libcairo2-dev \
26+
libpango1.0-dev \
27+
libjpeg-dev \
28+
libgif-dev \
29+
librsvg2-dev && \
30+
# Clean up apt lists to save space (Must be done at the very end!)
31+
rm -rf /var/lib/apt/lists/*
32+
33+
# 2. Build Phoenix
1334
WORKDIR /phoenix
1435
COPY . .
1536

16-
# 3. Environment: mark CI + skip Cypress binary (we only build static docs here)
37+
# Enable Corepack
38+
RUN corepack enable
39+
40+
# Force native modules (lmdb) to build from source
41+
ENV npm_config_build_from_source=true
42+
43+
# *** IMPORTANT FIX ***
44+
# Tell node-gyp explicitly to use the python3 executable we just installed
45+
ENV npm_config_python=/usr/bin/python3
46+
47+
# CI environment variables
1748
ENV CI=1
1849
ENV CYPRESS_INSTALL_BINARY=0
1950

20-
# 4. Use Yarn via Corepack (honors yarnPath -> Yarn 3 committed in repo)
21-
RUN corepack enable
22-
23-
# 5. Install dependencies & build the web/docs
51+
# Install dependencies (verbose so you can see if python errors occur)
2452
RUN yarn install --silent
53+
54+
# Build the web app
2555
RUN yarn deploy:web
2656

27-
# 6. Remove node_modules to keep final image lean
57+
# Remove node_modules folders to save space
2858
RUN find . -name "node_modules" -type d -exec rm -rf "{}" +
2959

30-
# 7. Runtime stage: just serve the built docs
60+
# 3. Serve the build through NGINX
3161
FROM nginx:alpine
3262
COPY --from=build /phoenix/packages/phoenix-ng/docs /usr/share/nginx/html

packages/phoenix-event-display/src/managers/three-manager/index.ts

Lines changed: 137 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,12 @@ export class ThreeManager {
9494
elem: Intersection<Object3D<Object3DEventMap>>,
9595
) => boolean;
9696
/** 'click' event listener callback to show 3D coordinates of the clicked point */
97-
private show3DPointsCallback: (event: MouseEvent) => void;
97+
private show3DPointsCallback: (event: MouseEvent) => void = () => {};
9898
/** 'click' event listener callback to shift the cartesian grid at the clicked point */
99-
private shiftCartesianGridCallback: (event: MouseEvent) => void;
99+
private shiftCartesianGridCallback: (event: MouseEvent) => void = () => {};
100100
/** 'click' event listener callback to show 3D distance between two clicked points */
101-
private show3DDistanceCallback: (event: MouseEvent) => void;
101+
private show3DDistanceCallback: (event: MouseEvent) => void = () => {};
102+
102103
/** Origin of the cartesian grid w.r.t. world origin */
103104
public origin: Vector3 = new Vector3(0, 0, 0);
104105
/** Scene export ignore list. */
@@ -122,7 +123,7 @@ export class ThreeManager {
122123
/** Color of the text to be displayed as per dark theme */
123124
private displayColor: string = 'black';
124125
/** Mousemove callback to draw dynamic distance line */
125-
private mousemoveCallback: (event: MouseEvent) => void;
126+
private mousemoveCallback: (event: MouseEvent) => void = () => {};
126127
/** Emitting that a new 3D coordinate has been clicked upon */
127128
originChanged = new EventEmitter<Vector3>();
128129
/** Whether the shifting of the grid is enabled */
@@ -1405,110 +1406,152 @@ export class ThreeManager {
14051406
* - Strech : current view is streched to given format
14061407
* this is the default and used also for any other value given to fitting
14071408
*/
1408-
public makeScreenShot(
1409+
/**
1410+
* Takes a very large screenshot safely by tiling renders
1411+
*/
1412+
public async makeScreenShot(
14091413
width: number,
14101414
height: number,
1411-
fitting: string = 'Strech',
1415+
fitting: string = 'Stretch',
14121416
) {
1413-
// compute actual size of screen shot, based on current view and requested size
1414-
const mainRenderer = this.rendererManager.getMainRenderer();
1417+
const renderer = this.rendererManager.getMainRenderer();
1418+
const camera = this.controlsManager.getMainCamera();
1419+
1420+
// ORIGINAL SCREEN SIZE
14151421
const originalSize = new Vector2();
1416-
mainRenderer.getSize(originalSize);
1417-
const scaledSize = this.croppedSize(
1418-
width,
1419-
height,
1420-
originalSize.width,
1421-
originalSize.height,
1422-
);
1423-
const heightShift = (scaledSize.height - height) / 2;
1424-
const widthShift = (scaledSize.width - width) / 2;
1422+
renderer.getSize(originalSize);
1423+
const originalWidth = originalSize.width;
1424+
const originalHeight = originalSize.height;
1425+
1426+
// ---------------------------
1427+
// 1. CROP & STRETCH LOGIC
1428+
// ---------------------------
1429+
let targetWidth = width;
1430+
let targetHeight = height;
1431+
let shiftX = 0;
1432+
let shiftY = 0;
1433+
1434+
if (fitting === 'Crop') {
1435+
const scaled = this.croppedSize(
1436+
width,
1437+
height,
1438+
originalWidth,
1439+
originalHeight,
1440+
);
1441+
1442+
targetWidth = scaled.width;
1443+
targetHeight = scaled.height;
1444+
1445+
shiftX = (scaled.width - width) / 2;
1446+
shiftY = (scaled.height - height) / 2;
1447+
}
1448+
1449+
// Stretch → KEEP exact width/height (NO crop, NO shift)
1450+
if (fitting === 'Stretch') {
1451+
shiftX = 0;
1452+
shiftY = 0;
1453+
targetWidth = width;
1454+
targetHeight = height;
1455+
}
1456+
1457+
// Fix aspect only for PerspectiveCamera
1458+
let originalAspect: number | undefined;
1459+
if (fitting === 'Stretch' && camera instanceof PerspectiveCamera) {
1460+
originalAspect = camera.aspect;
1461+
camera.aspect = width / height;
1462+
camera.updateProjectionMatrix();
1463+
}
1464+
1465+
// ---------------------------
1466+
// 2. Prepare output canvas
1467+
// ---------------------------
1468+
const output = document.getElementById(
1469+
'screenshotCanvas',
1470+
) as HTMLCanvasElement;
1471+
output.width = width;
1472+
output.height = height;
14251473

1426-
// get background color to be used
1427-
const bkgColor = getComputedStyle(document.body).getPropertyValue(
1474+
const ctxOut = output.getContext('2d')!;
1475+
const bg = getComputedStyle(document.body).getPropertyValue(
14281476
'--phoenix-background-color',
14291477
);
1478+
ctxOut.fillStyle = bg;
1479+
ctxOut.fillRect(0, 0, width, height);
14301480

1431-
// Deal with devices having special devicePixelRatio (retina screens in particular)
1481+
// ---------------------------
1482+
// 3. TILE RENDERING
1483+
// ---------------------------
14321484
const scale = window.devicePixelRatio;
1485+
const gl = renderer.getContext();
1486+
const maxSize = gl.getParameter(gl.MAX_RENDERBUFFER_SIZE);
14331487

1434-
// grab output canvas on which we will draw, and set size
1435-
const outputCanvas = document.getElementById(
1436-
'screenshotCanvas',
1437-
) as HTMLCanvasElement;
1438-
outputCanvas.width = width;
1439-
outputCanvas.height = height;
1440-
outputCanvas.style.width = (width / scale).toString() + 'px';
1441-
outputCanvas.style.height = (height / scale).toString() + 'px';
1442-
const ctx = outputCanvas.getContext('2d');
1443-
if (ctx) {
1444-
ctx.fillStyle = bkgColor;
1445-
ctx.fillRect(0, 0, width, height);
1446-
// draw main image on our output canvas, with right size
1447-
mainRenderer.setSize(
1448-
scaledSize.width / scale,
1449-
scaledSize.height / scale,
1450-
false,
1451-
);
1452-
this.render();
1453-
ctx.drawImage(
1454-
mainRenderer.domElement,
1455-
widthShift,
1456-
heightShift,
1457-
width,
1458-
height,
1459-
0,
1460-
0,
1461-
width,
1462-
height,
1463-
);
1488+
const tileW = Math.min(width, maxSize);
1489+
const tileH = Math.min(height, maxSize);
1490+
1491+
const tilesX = Math.ceil(width / tileW);
1492+
const tilesY = Math.ceil(height / tileH);
1493+
1494+
for (let ty = 0; ty < tilesY; ty++) {
1495+
for (let tx = 0; tx < tilesX; tx++) {
1496+
const offsetX = tx * tileW;
1497+
const offsetY = ty * tileH;
1498+
1499+
const w = Math.min(tileW, width - offsetX);
1500+
const h = Math.min(tileH, height - offsetY);
1501+
1502+
// FINAL effective offsets for camera
1503+
const effX = offsetX + shiftX;
1504+
const effY = offsetY + shiftY;
1505+
1506+
if (
1507+
camera instanceof PerspectiveCamera ||
1508+
camera instanceof OrthographicCamera
1509+
) {
1510+
camera.setViewOffset(targetWidth, targetHeight, effX, effY, w, h);
1511+
}
1512+
1513+
renderer.setSize(w / scale, h / scale, false);
1514+
this.render();
1515+
1516+
ctxOut.drawImage(
1517+
renderer.domElement,
1518+
0,
1519+
0,
1520+
w,
1521+
h,
1522+
offsetX,
1523+
offsetY,
1524+
w,
1525+
h,
1526+
);
1527+
}
14641528
}
14651529

1466-
mainRenderer.setSize(originalSize.width, originalSize.height, false);
1467-
this.render();
1530+
// Clear camera offset
1531+
if (
1532+
camera instanceof PerspectiveCamera ||
1533+
camera instanceof OrthographicCamera
1534+
) {
1535+
camera.clearViewOffset();
1536+
}
14681537

1469-
// Get info panel
1470-
const infoPanel = document.getElementById('experimentInfo');
1471-
if (infoPanel != null) {
1472-
// Compute size of info panel on final picture
1473-
const infoHeight =
1474-
(infoPanel.clientHeight * scaledSize.height) / originalSize.height;
1475-
const infoWidth =
1476-
(infoPanel.clientWidth * scaledSize.width) / originalSize.width;
1477-
1478-
// Add info panel to output. This is HTML, so first convert it to canvas,
1479-
// and then draw to our output canvas
1480-
const h2c: any = html2canvas;
1481-
// See: https://github.com/niklasvh/html2canvas/issues/1977#issuecomment-529448710 for why this is needed
1482-
h2c(infoPanel, {
1483-
backgroundColor: bkgColor,
1484-
// avoid cloning canvas in the main page, this is useless and leads to
1485-
// warnings in the javascript console similar to this :
1486-
// "Unable to clone WebGL context as it has preserveDrawingBuffer=false"
1487-
ignoreElements: (element: Element) => element.tagName == 'CANVAS',
1488-
}).then((canvas: HTMLCanvasElement) => {
1489-
canvas.toBlob((blob) => {
1490-
ctx?.drawImage(
1491-
canvas,
1492-
infoHeight / 6,
1493-
infoHeight / 6,
1494-
infoWidth,
1495-
infoHeight,
1496-
);
1497-
// Finally save to png file
1498-
outputCanvas.toBlob((blob) => {
1499-
if (blob) {
1500-
const a = document.createElement('a');
1501-
document.body.appendChild(a);
1502-
a.style.display = 'none';
1503-
const url = window.URL.createObjectURL(blob);
1504-
a.href = url;
1505-
a.download = `screencapture.png`;
1506-
a.click();
1507-
}
1508-
});
1509-
});
1510-
});
1538+
// Restore original aspect if changed
1539+
if (originalAspect !== undefined && camera instanceof PerspectiveCamera) {
1540+
camera.aspect = originalAspect;
1541+
camera.updateProjectionMatrix();
15111542
}
1543+
1544+
// Reset renderer size
1545+
renderer.setSize(originalWidth, originalHeight, false);
1546+
this.render();
1547+
1548+
output.toBlob((blob) => {
1549+
if (!blob) return;
1550+
const a = document.createElement('a');
1551+
a.href = URL.createObjectURL(blob);
1552+
a.download = 'screencapture.png';
1553+
a.click();
1554+
});
15121555
}
15131556

15141557
/**

packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/make-picture/make-picture.component.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,17 @@ export class MakePictureComponent implements OnInit {
2121
disabled: boolean = false;
2222
constructor(private eventDisplay: EventDisplayService) {}
2323
ngOnInit() {}
24-
private checkSize() {
25-
return this.eventDisplay
26-
.getThreeManager()
27-
.checkScreenShotCanvasSize(this.width, this.height, this.fitting);
28-
}
24+
2925
setWidth(value) {
3026
this.width = value;
31-
this.disabled = !this.checkSize();
27+
this.disabled = false;
3228
}
3329
setHeight(value) {
3430
this.height = value;
35-
this.disabled = !this.checkSize();
31+
this.disabled = false;
3632
}
3733
buttonText() {
38-
return this.disabled ? 'Size too large' : 'Create picture';
34+
return 'Create picture';
3935
}
4036
makePicture() {
4137
this.eventDisplay

0 commit comments

Comments
 (0)