Verify a render
A render manifest states what a render was made from and the hash of what it produced. Anyone with the original can check the result later: on your server with @sealpixel/render, or with the native CLI. The check re-renders and compares pixels, so a file that was tagged, re-wrapped or signed after the render still verifies.
Write the manifest
const result = await renderer.render({ original, editLog, manifest: true });
await save(`${result.hash}.jpg`, result.bytes);
await save(`${result.hash}.render.json`, JSON.stringify(result.manifest));manifest: true is off by default, because the pixel hash reads the whole rendered buffer once. It describes a truth render; a preview render with manifest: true is refused. The native CLI writes the same format with --manifest:
sealpixel render original.jpg plan.json result.jpg --manifest render.jsonCheck it
const report = await renderer.verify({ manifest, original, output });
if (!report.verified) {
for (const [name, check] of Object.entries(report.checks)) {
if (!check.ok) console.log(name, check.detail);
}
}sealpixel verify render.json --source original.jpg --output result.jpg✓ source sha256 9f3e…, 6126 x 4136
✓ renderer native 1.0.0-rc.15, same build
✓ pixels sha256 e4d9…
✓ output pixels identical, bytes differ (metadata or a signature added after the render)
VERIFIEDverify() takes the same assets as render() when the render was given fonts or images per request. The CLI exits 1 when the render is not verified. A failed check is a false in the report. verify() throws only for what makes the attempt impossible: a manifest it cannot read, an original it cannot decode or that is over a limit, or an abort. A manifest the CLI wrote has no edit log, so verify() reports editLog as failed and names sealpixel verify; the CLI, in turn, needs the plan and refuses a manifest whose render ran a kernel outside the core.
The checks
| Check | What passes | What it means when it fails |
|---|---|---|
source | The sha256 of the original equals the manifest's, and the decoded size equals the log's | Another file, or the original was changed |
editLog | The log is present, hashes to editLogHash, parses with the registered plugins, and describes the source the manifest describes | The manifest was edited, a plugin is missing, or the CLI wrote the manifest |
plugins | The registered plugin versions equal the ones the log was compiled with | Another plugin version would compile another plan |
pixels | Rendering the log on the original again gives the same pixel hash | The renderer, a plugin or a kernel produces other pixels now |
output | The given file decodes to the same pixels as the same encoding of the new render. Given only when output is passed; the file is held to the renderer's limits like an original | The file holds other pixels |
renderer | This build equals the one in the manifest: same kind, version and binary hash, both hashes known | Informative only. A different build that reproduces the pixels is still a verified render, and the report says both builds, or that a binary hash is unknown |
verified is true when every check except renderer passes. output also reports bytesIdentical: false after a signer or a tagger wrote into the file, which leaves the pixels, and the verdict, untouched.
A lossy output is never held against the raw pixels: the file is decoded and compared with the decoded form of the same JPEG encoding of the new render, so an identical file verifies, and a re-encoded one does not.
The manifest
Version 1. A field is added with a default, never renamed or removed; an incompatible change bumps version. The TypeScript type is RenderManifest in @sealpixel/core, the Rust struct sealpixel_core::manifest::RenderManifest.
| Field | Content |
|---|---|
version | 1 |
source | width and height after EXIF orientation, the hash of the original's bytes, mimeType when known |
editLog | { log, index } as the editor keeps it. Absent in a manifest the CLI wrote |
editLogHash | sha256 of editLog in canonical JSON, the text embedEditLog stores in a file |
plan | The core plan, when every stage ran in the core. The CLI verifies from it |
kernels | The stages that ran outside the core, when any did. Then there is no plan, and only verify() can check it |
plugins | The plugin versions the log was compiled with |
renderer | kind (wasm or native), version, and binarySha256 of the WebAssembly binary or the native executable |
output | options as given to the core, never with embed; embedEditLog when the file holds the log; width, height; pixelHash over the rendered pixels; byteHash of the written bytes |
renderHash | One id for the render |
The pixel hash is sha256 over the width and the height as big-endian 32-bit integers, then the RGBA bytes row by row. It is the hash of the render; hash on the result stays the hash of the file.
renderHash is sha256 over a fixed text, so every runtime agrees on it without canonical-JSON rules:
sealpixel-render/1
<source hash>
<edit log hash, or the plan hash in a manifest without a log>
<plugins as id@version, sorted by id, joined by ','>
<renderer kind> <renderer version> <binary sha256 or '-'>
<pixel hash>
<byte hash>verify does not recompute it. It checks the facts.
From here to Content Credentials
manifestDefinitionFor(manifest, options) in @sealpixel/c2pa turns a render manifest into a C2PA manifest definition: the same log, the same index, the renderer's version as the software agent.
The renderer's identity
binarySha256 tells two builds of one version apart: a release that changed pixels in a patch is visible in the manifest. The WebAssembly loader hashes the binary it reads from disk; in the browser, where the binary streams from its URL, it reports the hash recorded at build time for the binary that ships next to the glue. A module passed as a URL, a Response or a compiled WebAssembly.Module gives null. The native CLI hashes its own executable.