Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ Every non-headless session shows a small panel bottom-left of the browser view:
* a pause/play button (`||`/`▶`) — also bound to the spacebar;
* a realtime-speed selector (Max/1x/0.5x/0.25x) — `1x` matches `env.launch(realtime=True)`, `Max` matches the default (`realtime=False`, uncapped). `env.launch(realtime=0.5)` (a specific float, not just `True`/`False`) sets an initial speed directly.

Pressing `s` anywhere in the browser tab (outside a text input) saves a screenshot of the current view, named `swift-YYYY-MM-DD_HH-MM-SS.png` — the same mechanism as `env.screenshot()`, just without a Python round-trip.

## Recording video

Any scene can be recorded straight from Python — call `env.start_recording(...)` around the part you want captured, `env.stop_recording()` when done. The `.webm` file downloads automatically once encoding finishes:
Expand Down
5 changes: 5 additions & 0 deletions docs/source/intro.rst
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ view:
(``realtime=False``, uncapped). ``env.launch(realtime=0.5)`` (a specific
float, not just ``True``/``False``) sets an initial speed directly.

Pressing ``s`` anywhere in the browser tab (outside a text input) saves a
screenshot of the current view, named ``swift-YYYY-MM-DD_HH-MM-SS.png`` --
the same mechanism as :meth:`~swift.Swift.Swift.screenshot`, just without a
Python round-trip.

See `Swift's own README
<https://github.com/jhavl/swift#readme>`_ for a full set of worked
examples of increasing complexity -- moving shapes with sliders, robots
Expand Down
27 changes: 14 additions & 13 deletions src/swift/public/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Slider, Button, Label, Select, Checkbox, Radio } from "./ui.js";
import { WebSocketTransport, portFromLocation, SWIFT_JS_VERSION } from "./comms.js";
import { Recorder } from "./recording.js";
import { FPS, SimTime } from "./hud.js";
import { saveScreenshot, timestampedScreenshotName } from "./screenshot.js";

const { scene, camera, renderer, controls, axesHelper, ground, groundMaterial, lights } = createScene();

Expand All @@ -27,24 +28,24 @@ const UI_CLASSES = { slider: Slider, button: Button, label: Label, select: Selec
// connects (see Swift.py's launch(browser_timeout=)). null means never.
let autoCloseDelay = null;

function saveScreenshot(fileName) {
const link = document.createElement("a");
link.download = `${fileName}.png`;
link.href = renderer.domElement.toDataURL("image/png");
link.click();
}

// launch()'s _add_controls() always adds Pause/Realtime/Render as the
// first three elements, right after connecting and before any user code
// runs -- so id 0 is reliably the pause button. Space just simulates a
// click on it, reusing the exact same click -> "changed" -> shape_poses
// response path a mouse click would take.
window.addEventListener("keydown", (e) => {
if (e.code !== "Space" || e.target.tagName === "INPUT") return;
const pauseButton = uiElements.find((el) => el.id === 0);
if (pauseButton?.button) {
e.preventDefault();
pauseButton.button.click();
if (e.target.tagName === "INPUT") return;

if (e.code === "Space") {
const pauseButton = uiElements.find((el) => el.id === 0);
if (pauseButton?.button) {
e.preventDefault();
pauseButton.button.click();
}
} else if (e.code === "KeyS" && !e.ctrlKey && !e.metaKey && !e.altKey) {
// Bare 's' -- Ctrl/Cmd+S is left alone so it still triggers the
// browser's own "Save Page As", rather than fighting it.
saveScreenshot(renderer.domElement, timestampedScreenshotName());
}
});

Expand Down Expand Up @@ -186,7 +187,7 @@ transport.onMessage((func, data) => {
break;
}
case "screenshot": {
saveScreenshot(data[0]);
saveScreenshot(renderer.domElement, data[0]);
transport.send(0);
break;
}
Expand Down
20 changes: 20 additions & 0 deletions src/swift/public/js/screenshot.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/** Screenshot saving: canvas -> PNG download, plus the auto-generated
* filename used by the 's' hotkey (env.screenshot()'s own file_name
* argument covers the explicit-name case). */

// Colons aren't valid in Windows filenames, so the timestamp uses dashes
// throughout rather than the `HH:MM:SS` grouping a clock display would use.
export function timestampedScreenshotName() {
const pad = (n) => String(n).padStart(2, "0");
const now = new Date();
const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
const time = `${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
return `swift-${date}_${time}`;
}

export function saveScreenshot(canvas, fileName) {
const link = document.createElement("a");
link.download = `${fileName}.png`;
link.href = canvas.toDataURL("image/png");
link.click();
}
30 changes: 30 additions & 0 deletions src/swift/public/js/screenshot.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import { test } from "node:test";

import { saveScreenshot, timestampedScreenshotName } from "./screenshot.js";

test("timestampedScreenshotName matches swift-YYYY-MM-DD_HH-MM-SS, no colons", (t) => {
const name = timestampedScreenshotName();
assert.match(name, /^swift-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$/);
});

test("saveScreenshot encodes the canvas as a PNG and clicks a download link", (t) => {
const originalDocument = globalThis.document;
let created;
globalThis.document = {
createElement: () => {
created = { clicked: false, click() { this.clicked = true; } };
return created;
},
};
t.after(() => {
globalThis.document = originalDocument;
});

const canvas = { toDataURL: (type) => `data:${type};base64,stub` };
saveScreenshot(canvas, "swift-2026-08-22_10-00-00");

assert.equal(created.download, "swift-2026-08-22_10-00-00.png");
assert.equal(created.href, "data:image/png;base64,stub");
assert.equal(created.clicked, true);
});
Loading