pts-render
v0.1.2
Published
Render portable and classic Pts scenes to PNG and SVG with a deterministic Node.js CLI and API
Readme
pts-render
Render Pts.js scenes to PNG, JPEG, WebP, raw RGBA, and SVG in Node.js. The package uses skia-canvas and provides:
- the
pts-rendercommand for agents, scripts, and build pipelines; - a deterministic
renderScene()API; - a portable JavaScript format that runs in Node and a normal browser CanvasSpace;
- a compatibility loader for a tested subset of unchanged Pts demos; and
- the lower-level
SkiaCanvasSpaceandSkiaCanvasFormadapter.
Pts itself is never patched. All Node integration lives in this repository.
Status
This is the 0.1.2 release for published
Pts 1.0.3. Both the npm package and the
installed command are named pts-render.
Command line
Node.js 20 or later is required. You can render a file without installing the package globally:
npx pts-render drawing.mjsFor unattended or reproducible use, suppress npm's install prompt and pin the version:
npx --yes [email protected] drawing.mjs --jsonThe package installs one executable named pts-render, so the persistent form
is:
npm install --global pts-render
pts-render drawing.mjsskia-canvas downloads its prebuilt native binary in an install script. npm 11
warns that the script is "not yet covered by allowScripts"; it still runs, and
npm install --global --allow-scripts=skia-canvas pts-render silences the
warning. Without the script, rendering fails at startup with a missing
skia.node.
With npx or a global install, the runner's copy of Pts lives in the npm cache
or global prefix, so a render file cannot import it directly. Use the Pts
namespace passed to run instead; it is the runner's own instance. Direct
imports work only when the file resolves pts to the same installation, such as
a project that installed pts-render locally.
From this checkout
pnpm install --frozen-lockfile
pnpm build
node dist/cli.mjs examples/basic-card.mjs \
--jsonIn a shell without a TTY, such as an agent or CI job, set CI=true so pnpm can
replace an existing node_modules without prompting.
With no --out, this creates a unique PNG such as
pts-output/basic-card-550e8400-e29b-41d4-a716-446655440000.png. To choose
names or generate multiple formats explicitly:
node dist/cli.mjs examples/basic-card.mjs \
--out out/basic-card.png \
--out out/basic-card.svg \
--jsonOutput directories are created as needed. Existing files are protected unless
--force is supplied. A trailing slash selects a directory and asks the CLI to
generate the filename; an existing directory passed without a trailing slash is
rejected with OUTPUT_TARGET_INVALID and a hint about the trailing-slash form.
Symlinks, other non-regular targets, and duplicate canonical paths are also
rejected before scene execution. --background and --matte must be colors
that skia-canvas can parse (named colors, transparent, hex, rgb(), hsl(),
or hwb()); anything else is a usage error rather than a silently wrong color.
Running pts-render with no arguments is also a usage error; --help prints
the option list.
Write a render file
A render file is ordinary JavaScript with one default-exported function. The CLI
calls run once so the file can register its normal Pts drawing callback:
function run({ Pts, space, form, params }) {
const { Circle } = Pts;
const radius = Number(params.radius ?? 40);
space.add((time) => {
form
.fillOnly("#67e8f9")
.circle(Circle.fromCenter(space.pointer, radius + time / 1000));
});
}
export default run;Render it in Node:
pts-render circles.mjs \
--size 640x360 \
--pointer 320,180 \
--time 1000 \
--param radius=48 \
--out circles.png--param values are parsed as JSON when they are valid JSON and kept as strings
otherwise, so --param radius=48 yields the number 48, --param flag=true a
boolean, --param 'obj={"a":1}' an object, and --param label=hello the string
"hello". The example above still coerces with Number() so the same file also
accepts string parameters from a --params file or a browser mount.
The canvas is 800x600 with a transparent background when neither the file nor
the command specifies them. --size and --background override file values. To
keep defaults in the file, export a configured object instead:
export default {
width: 640,
height: 360,
background: "#10131a",
run,
};pts-render/scene exports defineScene(), a type-level identity helper. It
gives the configured object editor completion and type checking (for example,
width and height must appear together and unknown keys are flagged) and
returns it unchanged; runtime validation still happens when the file is rendered
or mounted:
import { defineScene } from "pts-render/scene";
export default defineScene({ width: 640, height: 360, run });It has no Node or skia-canvas dependency, so the same file works in the browser.
A render file that imports it must be able to resolve pts-render from its own
location; with npx or a global install, no local package exists, so prefer a
plain object or function export there.
Quick testing in browser
Mount the exact same function or configured object in a browser:
import { mountScene } from "pts-render/browser";
import run from "./circles.mjs";
const mounted = await mountScene(run, {
target: "#pt",
params: { radius: 48 },
});
// Later:
await mounted.dispose();pts-render/browser has no Node built-in, skia-canvas, or native dependency
edge in its module graph. It creates a real Pts CanvasSpace and CanvasForm,
preserves the same initial resize/start ordering as the Node runner, and owns
input binding and playback. It intentionally rejects SVGSpace targets, and it
rejects a target selector that matches no element instead of letting Pts
append a new container to the page.
The module imports the bare specifiers pts-render/browser and pts, so the
page needs a bundler, an import map, or a CDN that resolves them. Without a
build step, an import map over a local install works:
<script type="importmap">
{
"imports": {
"pts-render/browser": "./node_modules/pts-render/dist/browser.mjs",
"pts": "./node_modules/pts/dist/index.mjs"
}
}
</script>A CDN that rewrites dependencies, for example
https://esm.sh/[email protected]/browser, also resolves pts on its own.
mountScene(scene, options) accepts:
| Option | Purpose |
| ------------------------ | ------------------------------------------------------------------------ |
| target | Selector or element for the container (required) |
| size | { width, height } for a fixed canvas; overrides file values |
| background | Canvas background; overrides the file value |
| resize | Follow the container size; defaults to true only when no size is known |
| retina | Scale for the device pixel ratio; default true |
| params | Parameter object passed to run |
| assetBaseURL | Base for assets.*; defaults to the page |
| bindMouse, bindTouch | Pointer input; default true |
| autoplay | Start playback after mounting; default true |
| signal | AbortSignal that cancels mounting or disposes the scene |
| onWarning | Callback for warnings that the result also collects |
It resolves to { space, form, warnings, dispose }. When the options or the
file specify a size, the canvas keeps that size like the Node renderer's; an
unsized mount fills its container and follows it. Pts measures the container to
size the canvas, so give each mount its own container that holds nothing else.
The created canvas is styled display: block so that a container without an
explicit height does not grow a few pixels per resize.
Delta from a standard web demo
Most drawing and player code can stay unchanged. The meaningful differences are at the edges:
| Standard browser demo | Render file |
| --------------------------------------------- | ------------------------------------------------- |
| Pts.quickStart("#pt", bg) | file or CLI background |
| container determines initial size | file, --size, or the 800x600 default |
| globals such as Circle, space, and form | destructure Pts, space, and form in run |
| space.bindMouse().bindTouch().play() | browser mount or CLI runner owns scheduling/input |
| browser-relative Img or font loading | assets.image() and assets.font() |
| live browser clock | explicit --time or --frame simulation |
The callback itself remains normal Pts code. See
examples/basic-card.mjs for the canonical example.
CLI
pts-render <source> [--out <destination>] [options]
pts-render render <source> [--out <destination>] [options]Core options:
| Option | Purpose |
| ---------------------------------- | ---------------------------------------------------------------------- |
| -o, --out <path> | Exact file or trailing-/ directory; repeatable; - writes to stdout |
| --format <name> | Generated, ambiguous, or stdout format; required with --out - |
| --loader <auto\|scene\|pts-demo> | Source interpretation |
| --size <width>x<height> | Canvas size; overrides file values; otherwise defaults to 800x600 |
| --background <color> | Canvas background; overrides file value; otherwise transparent |
| --pointer <x>,<y> | Initial pointer without dispatching an action |
| --time <ms> | One direct frame at a timestamp |
| --frame <index> --fps <rate> | Simulate frames 0 through the index |
| --events <file.json> | Replay a deterministic pointer/action/resize timeline |
| --seed <string> | Seed Pts and isolated JavaScript random streams |
| --params <file.json> | Base parameter object |
| --param <key=value> | Parameter override; repeatable |
| --density <integer> | Raster output scale |
| --quality <0..1> | JPEG/WebP quality |
| --matte <color> | Color beneath raster transparency |
| --text-mode <preserve\|outline> | SVG text policy |
| --asset-root <path\|URL> | Base for scene/legacy assets |
| --allow-net | Allow HTTP(S) through the asset service |
| --font <family=path> | Register a font; repeatable by family |
| --renderer <cpu\|auto\|gpu> | Native renderer policy |
| --timeout <ms> | Hard worker deadline |
| --limit <name=value> | Resource-limit override; repeatable |
| --force | Replace existing outputs |
| --json | Emit exactly one machine-readable result record |
| --quiet | Suppress human diagnostics and captured scene logs |
| --debug | Include stack information in JSON errors |
Defaults and generated outputs
Only the source is required. When --out is omitted, the CLI creates one PNG
under pts-output/ in the current working directory:
pts-output/<sanitized-source-stem>-<render-id>.pngThe render ID is a full random UUID, so simultaneous agent processes do not
choose the same path. The absolute committed path and the same renderId are
reported in --json output. Human-readable mode prints the path to stderr.
--quiet intentionally suppresses that message, so callers using a generated
name should use --json when they need to discover it programmatically.
--format selects the generated extension:
pts-render scene.mjs --format svg --text-mode outline --json
pts-render scene.mjs --out renders/ --format webp --jsonThe second form creates a unique file inside renders/. A bare --out remains
an error; omit it to use pts-output/. Multiple outputs still require repeated
explicit --out destinations. --out - streams one output to stdout and has no
extension to infer from, so it always needs --format:
pts-render scene.mjs --out - --format png > scene.pngOther effective defaults are:
| Setting | Default or fallback |
| ---------------------------- | ------------------------------------------------------ |
| loader | Syntax-aware auto selection |
| logical size | File dimensions, otherwise 800x600 |
| background | File/classic-demo background, otherwise transparent |
| pointer | Canvas center |
| clock | One direct frame at 0ms with a zero delta |
| simulated frame rate | 60fps when --frame is selected |
| events | Empty timeline |
| random seed | Unset; randomness is not made deterministic implicitly |
| parameters | Empty object |
| raster density | 1 |
| JPEG/WebP quality | 0.92 from skia-canvas |
| raster matte | Unset |
| SVG text mode | preserve |
| assets | Scene-relative, with network access disabled |
| additional fonts | None |
| renderer | CPU |
| timeout | 30000ms |
| overwrite | Disabled |
| JSON, quiet, and debug modes | Disabled |
--limit inherits the exported DEFAULT_RENDER_RESOURCE_LIMITS values. Flags
such as --background, --seed, and --font are optional overrides rather
than arbitrary implicit values.
--time 5000 invokes one frame with (time, delta) = (5000, 0). It does not
invent intermediate frames. Stateful sketches should use, for example,
--frame 300 --fps 60, which invokes frames 0 through 300 with timestamps
computed from their integer indexes.
Deterministic input
An event file is versioned and strictly validated:
{
"schemaVersion": 1,
"events": [
{ "at": 0, "type": "move", "x": 100, "y": 100 },
{ "at": 500, "type": "down", "x": 100, "y": 100, "buttons": 1 },
{ "at": 800, "type": "drag", "x": 220, "y": 160, "buttons": 1 },
{ "at": 900, "type": "up", "x": 220, "y": 160 },
{ "at": 1000, "type": "resize", "width": 800, "height": 600 }
]
}Events are stably ordered by at and source order. --pointer changes initial
state only; it does not synthesize a move callback. Each at is a millisecond
timestamp on the selected render clock. Before a frame, the runner dispatches
all events whose timestamp is at or before that frame and then calls the normal
Pts action(type, x, y, event) handlers. Pointer actions update the synthetic
pointer state; resize events update the canvas and invoke its resize lifecycle.
With --time 1000, applicable events are replayed through 1000ms and then one
frame is rendered. With --frame 60 --fps 60, intermediate frames are rendered
and each event is applied before the first frame boundary that reaches it.
Events after the final render time are not dispatched. The timeline supports Pts
pointer/action types and resize, not keyboard input or an emulated browser event
loop.
Agent-facing JSON
Success writes one JSON object to stdout:
{
"ok": true,
"schemaVersion": 1,
"renderId": "550e8400-e29b-41d4-a716-446655440000",
"loader": "scene",
"width": 640,
"height": 360,
"outputs": [
{
"format": "png",
"bytes": 12345,
"sha256": "...",
"path": "/absolute/path/pts-output/circles-550e8400-e29b-41d4-a716-446655440000.png"
}
]
}The example is abridged: the real record also carries source, render (clock
facts), random (seed facts), runtime (Node, Pts, skia-canvas, and renderer
facts), warnings, captured logs, and durationMs. renderScene() returns
the same fields except renderId, which the CLI assigns for its generated
filenames. Failures use
{ "ok": false, "error": { "code", "phase", "message", "cause"? } } plus
schemaVersion and, once assigned, renderId, and retain a bounded cause chain
without stacks by default. --debug adds worker and cause stacks. Exit groups
are stable: 2 for usage/validation, 3 for source or compatibility, 4 for
render-file initialization/input/frame/cleanup, 5 for export/commit, 6 for
native/environment, 124 for timeout, and 130 for SIGINT. --json cannot be
combined with --out -.
Programmatic one-shot API
import { renderScene } from "pts-render";
const result = await renderScene("./circles.mjs", {
pointer: [320, 180],
seed: "release-card",
render: { mode: "frame", frame: 60, fps: 60 },
outputs: [
{ format: "png" }, // returned as Buffer
{
format: "svg",
path: "out/circles.svg",
textMode: "outline",
},
],
timeoutMs: 30_000,
});
const png = result.outputs[0].buffer;Each call starts a fresh worker. Outputs are encoded in request order from the same final canvas. All artifacts and Buffer limits are verified before any path is committed. Each path commit is atomic, but multiple paths are not a single filesystem transaction: a later commit failure reports already committed paths in the error details. Omitted paths become bounded Buffers in the parent. The result includes dimensions, clock facts, random facts, renderer facts, warnings, hashes, captured logs, and optional scene metadata.
Render files must have a default export containing either a run function or a
configured object with a run property. .mjs, package-appropriate .js, and
.cjs (module.exports or exports.default) are supported. Direct ESM imports
from pts must resolve to the runner's exact peer installation and module
instance; otherwise rendering fails with PTS_INSTANCE_MISMATCH. A scene's
require("pts") selects Pts's separate CommonJS implementation and is rejected,
even from the same installation, because it would split Pts state and seeded
randomness. CommonJS scenes should use context.Pts or await import("pts").
Avoid separate Pts bundles or transitive dependencies that introduce another Pts
instance; the preflight checks direct literal references, not an arbitrary
dependency graph. Using context.Pts is the simplest portable choice.
Assets and fonts
Portable code uses one API in Node and the browser:
export default {
assetBaseURL: import.meta.url,
async run({ space, form, assets }) {
const image = await assets.image("./artwork.webp");
await assets.font({
family: "Project Sans",
sources: ["./project-sans.woff2"],
});
space.add(() => {
form.image(
[
[0, 0],
[320, 180],
],
image,
);
form.font(24, "normal", "normal", 1.2, "Project Sans");
});
},
};Node resolves assets relative to the source module unless assetBaseURL or
--asset-root overrides it. Network access is denied by default, redirects and
bytes are bounded, and registered resources live only for the render worker.
Local files and data: URLs work without network permission. An explicit asset
root confines file resolution through assets.* both lexically and after
symlink resolution; explicit --font paths still resolve from the caller's
working directory. Failed image loads can be retried; identical font
registrations are idempotent, while reusing a family with different sources is
an error. Diagnostics redact URL credentials, query strings, and data payloads.
The browser uses HTMLImageElement, FontFace, and the same scene-relative URL
rules; browser image failures also identify loading/CORS policy without exposing
URL credentials.
SVG output
SVG is available at both levels:
await space.toFile("drawing.svg", { outline: false });
const svg = await space.toBuffer("svg", { outline: true });In the CLI/API, textMode: "preserve" retains text where Skia can do so;
"outline" converts glyphs to paths for more self-contained geometry. Embedded
raster images remain raster data inside the SVG. Preserved text names the font
Skia actually used, not the CSS family the scene asked for: a font registered as
"Project Sans" from a DejaVu file is written as font-family="DejaVu Serif",
and a generic sans-serif becomes the resolved system face. A viewer without
that font substitutes its own, so outline is the portable choice when the SVG
leaves the machine that rendered it.
Skia's SVG encoder omits Canvas-filtered drawing commands. If a scene sets a
non-none Canvas filter, the adapter preserves the complete drawing by
embedding a PNG snapshot in the SVG at logical resolution (density 1). The
CLI/API reports SVG_RASTER_FALLBACK; the low-level adapter exposes
space.svgRasterFallback. All content, including text, is rasterized in this
case regardless of textMode. Unfiltered scenes retain normal vector SVG
output. This conservative fallback remains enabled for the canvas lifetime, even
if the scene later resets the filter.
Filters, compositing, text, fonts, and backend details can differ from a browser Canvas implementation, so visual compatibility is tested semantically rather than by byte equality.
Low-level adapter
Use SkiaCanvasSpace directly when a worker and scene schema are unnecessary:
import { Circle } from "pts";
import { SkiaCanvasSpace } from "pts-render";
const space = new SkiaCanvasSpace(640, 360, {
background: "#10131a",
renderer: "cpu",
});
const form = space.getForm();
space.setPointer([320, 180]);
space.add((time) => {
form.fillOnly("#67e8f9").circle(Circle.fromCenter(space.pointer, 40));
});
space.renderFrame(1000);
await space.toFile("circle.png", { density: 2 });
await space.toFile("circle.svg", { outline: false });
space.dispose();The adapter is intentionally explicit:
renderFrame()is deterministic and non-reentrant;setPointer()anddispatchAction()provide synthetic input;resize()preserves explicitly supplied pointer state;- exports do not advance the scene;
- mutation is blocked while asynchronous export is pending; and
- browser scheduling methods such as
play()throw with arenderFrame()alternative.
CPU is the default. renderer: "auto" permits Skia fallback; "gpu" is strict
and fails if the actual engine is not GPU-backed. Allocation limits are checked
before native Canvas construction.
Trust and resource boundary
Render files and classic demos execute arbitrary JavaScript. The worker and VM improve lifecycle control, diagnostics, and timeout behavior; they are not a security sandbox for hostile code. Render only trusted sources, especially when network assets are enabled.
Default limits cover source/input bytes, logical and raster pixels, frames, events, outputs, assets, artifact bytes, returned Buffers, metadata, logs, and seed size. A timeout or SIGINT kills the worker. Scene code cannot choose output paths because output policy remains in the parent process.
Verification
pnpm checkThe release gate includes formatting, lint, source types, coverage-enforced runtime tests, real Chromium mounting/abort/image tests, CLI black-box tests, all 24 compatibility cases, strict Node16/NodeNext consumer types, a clean-source package build and fresh npm consumer install, and package/export validation. The packed consumer installs the registry peer and native runtime dependencies without borrowing development symlinks.
pnpm test:coverage writes HTML and JSON reports to coverage/. Its thresholds
are 82% lines, 80% statements, 85% functions, and 70% branches for instrumented
runtime modules. Browser and process entry points are exercised by separate
end-to-end suites, not included in those percentages.
Compatibility fixtures are vendored unchanged from the exact release source, so the compatibility suite needs neither network access nor a neighboring Pts checkout. To verify an optional external checkout of the same release:
pnpm build
pnpm test:compatibility /path/to/pts-at-6f666faThe compatibility verifier checks the commit when Git metadata is present,
checks source and asset hashes, decodes output to verify dimensions and visible
content, verifies required SVG image content, writes output to its own temporary
directory, and compares the Pts checkout's status/diff fingerprints before and
after. The installed runtime remains npm [email protected].
Publishing checklist
pnpm install --frozen-lockfile
pnpm check
pnpm audit --prod
pnpm packRequire green CI results for Linux, macOS, and Windows on Node 20, 22, and 24
before publishing. Confirm the package name/version, npm ownership, and release
notes, then publish from this checkout with npm publish. prepublishOnly
reruns pnpm check; prepack always rebuilds the distributed files, including
when packing a clean checkout. Browser tests require Chromium; install it with
pnpm exec playwright install chromium if it is not already available. No
package is published by the verification commands.
Rendering unchanged Pts demos
The classic compatibility loader evaluates a browser demo once in an isolated
worker VM and supplies Pts globals, Pts.quickStart, a private CanvasSpace
facade, deterministic lifecycle methods, and narrowly supported Canvas globals:
pts-render path/to/pts/demo/circle.intersectCircle2D.js \
--loader auto \
--size 640x360 \
--pointer 320,180 \
--out circle.png \
--out circle.svgAuto-selection is syntax-aware. It parses without evaluation and chooses the
classic loader only for strong markers such as Pts.quickStart,
window.demoDescription, or a known Space constructor. A source containing both
module exports and classic markers is rejected as ambiguous; choose
--loader scene or --loader pts-demo explicitly.
Classic support is manifest-based, not a blanket “all demos” claim. For Pts 1.0.3, the checked matrix contains:
- 17 supported demos;
- 4 supported-with-input demos;
- 1 partial editable/pixel Img demo; and
- 2 browser-only/audio demos marked not applicable.
The supported set covers geometry, gradients, text, compositing, resize,
actions, Tempo, physics, UI, direct CanvasSpace construction, non-editable image
loading, and image patterns. Every source and image asset is SHA-256 pinned;
every supported entry produces both PNG and SVG with visible content. The
filtered-image demo uses the explicit SVG raster fallback described below. See
compatibility/pts-revamp.json.
Root-relative legacy image paths require an explicit root instead of guessing the machine filesystem:
pts-render path/to/pts/demo/guide.image_load.js \
--asset-root path/to/pts \
--out image-demo.pngEditable Img canvases, pixel manipulation, audio, HTMLSpace, and SVGSpace fail at a named compatibility boundary. SVG file export remains available because it comes from the Canvas recording, not SVGSpace.
Current limitations
- Classic compatibility is manifest-scoped; it is not a DOM emulator.
- Editable/pixel-oriented legacy Img, offscreen Canvas helpers, HTMLSpace, SVGSpace, audio, microphone, video, and arbitrary browser timers are not supported.
- SVG records Canvas output and does not provide semantic SVGSpace nodes.
- Native deployment, fonts, and platform rendering differences still need to be treated like any other skia-canvas application.
