Docs
falla finds visual bugs and writes the same report wherever you run it. Pick the way that fits your pipeline.
Web page
Open the home page and drop a .glb scene (one file), or a video, on the drop zone. The analysis runs in your browser, on your device. Scenes need WebGL. Videos up to 60 seconds must say how long they are; a screen or browser recording often does not, so save it again first.
The sample is the same analysis on a synthetic tram interior, so you can see the result before you use your own file.
Install
The web page works today, in your browser, with nothing to install. The command line, the Node library, the MCP server and the GitHub Action described below come out with the source code, which is not public yet. They need Node 22 or newer, and the package will be @nerln/falla: npm install -g @nerln/falla installs falla and falla-mcp.
The code is released under the PolyForm Noncommercial License 1.0.0: free for personal, research and other noncommercial use. Commercial use needs a separate licence from the author.
Video needs ffmpeg: brew install ffmpeg on a Mac, sudo apt-get install -y ffmpeg on Linux. Scenes need Chrome's headless shell: npx @puppeteer/browsers install chrome-headless-shell@stable --path ~/.cache/puppeteer. FFMPEG and FALLA_CHROME point falla at other copies. When one is missing, falla says so in one line, with the command to install it.
Command line
The command line runs the same analysis and writes a folder with candidates.json, one crop per candidate and a full frame for context. falla --help lists every command.
falla video clip.mp4 --out out/clip --fps 15 --width 320
falla scene scene.glb --out out/scene --samples 60 --size 960x540
falla report out/clip
falla confirm out/clip --top 8 --dry-run
falla eval out/clip truth.json --min-recall 0.8
A scene runs in Chrome or Chromium. Set FALLA_CHROME to the browser you want. falla report writes an HTML page and a Markdown file next to the JSON. falla confirm sends the crops to Claude with your own API key (ANTHROPIC_API_KEY), in one request, and --dry-run shows what would be sent without calling anything.
Node library
The library gives the same objects as the command line, and writes nothing you did not ask for. Add it to your project with npm link @nerln/falla (after the link step above), or from npm once the package is published.
import { analyzeScene, analyzeVideo, makeReport, confirm } from "@nerln/falla";
const report = await analyzeVideo("render.mp4", { out: "out/render" });
for (const c of report.candidates) console.log(c.severity, c.kind, c.t, c.explanation);
await makeReport(report.outDir); // report.html and report.md
await confirm(report.outDir, { top: 5 }); // a dry run unless dryRun: false
analyzeScene(file, options) takes a .glb, a .gltf or a three.js module, with cameraPath, samples, size, content and out. analyzeVideo(file, options) takes fps, width and out. Each one resolves to the report as candidates.json holds it, plus two properties that stay out of the JSON: outDir, the absolute folder it was written to, and output, what the command printed. A failed run rejects with a FallaError.
confirm(outDir, options) asks Claude about the top candidates (top, model). It is a dry run unless you pass dryRun: false, and then it needs ANTHROPIC_API_KEY.
MCP server
An agent that builds a 3D scene can see what it made. falla-mcp speaks MCP over stdio and offers four tools. falla_analyze_scene and falla_analyze_video answer with one line per candidate, the crops of the top three as images, and the path of the full report. falla_report writes the HTML and Markdown pages. falla_confirm asks Claude: it is a dry run unless dry_run is false, and a real run needs ANTHROPIC_API_KEY (or FALLA_CONFIRM_ENDPOINT) in the environment the server starts with.
In Claude Code:
claude mcp add --scope user falla -- falla-mcp
Any other client that starts MCP servers from a command takes this entry:
{ "mcpServers": { "falla": { "command": "falla-mcp", "args": [] } } }GitHub Action
The action runs falla on a .glb, .gltf or .mjs scene, or on a video, in your repository. It uploads the report as an artifact, writes a summary on the run page, and fails the job when a candidate is at or above fail-on.
- uses: actions/checkout@v4
- uses: nerln/falla@main
with:
path: assets/scene.glb # .glb, .gltf, .mjs/.js module, or a video
fail-on: high # high, medium, low or none
args: --path assets/camera.json
Inputs: path (required), fail-on (default high), out (default falla-out), args (extra options for falla scene or falla video, split on spaces, without quotes) and artifact-name (default falla-report, so each job of a matrix can have its own). Outputs: candidates, high and report.
Pipelines
Three complete examples, in the examples/ folder of the repository. Each one runs as it is on the synthetic test video that the repository makes (fixtures/video/video-dirty.mp4, from npm run make:fixtures), and each one exits with 1 when it finds a high-severity candidate, so it can stop a build. Each needs the falla command on your PATH (the link step in Install), or the library linked into your project.
Shell: fail on a high-severity bug
Run it with sh examples/fail-on-high.sh scene.glb, or with a video.
#!/bin/sh
# Fails when falla finds a high-severity visual bug in a scene or a video.
# Usage: sh examples/fail-on-high.sh <scene.glb|scene.gltf|video.mp4> [out-dir]
set -eu
file="$1"
out="${2:-out/check}"
case "$file" in
*.mp4|*.mov|*.webm|*.mkv) falla video "$file" --out "$out" ;;
*) falla scene "$file" --out "$out" ;;
esac
# candidates.json is written with one "severity" per candidate, so counting the high ones is enough
high=$(grep -c '"severity": "high"' "$out/candidates.json" || true)
if [ "$high" -gt 0 ]; then
falla report "$out" >/dev/null
echo "falla: $high high-severity candidate(s) in $file; open $out/report.html" >&2
exit 1
fi
echo "falla: no high-severity candidates in $file"
Node: the library
Run it with node examples/node-library.mjs video.mp4.
// Node: call falla as a library and act on the report it returns.
// Usage: node examples/node-library.mjs [video.mp4]
import { analyzeVideo, makeReport } from "@nerln/falla";
const file = process.argv[2] ?? "fixtures/video/video-dirty.mp4";
const report = await analyzeVideo(file, { out: "out/node-example" });
await makeReport(report.outDir);
for (const c of report.candidates) {
console.log(`${c.id} ${c.severity} ${c.kind} t=${c.t}s ${c.explanation}`);
}
const high = report.candidates.filter((c) => c.severity === "high");
console.log(`${high.length} high-severity candidate(s); report: ${report.outDir}/report.html`);
process.exitCode = high.length > 0 ? 1 : 0;
Python: subprocess and candidates.json
Run it with python3 examples/read_report.py video.mp4. It needs nothing beyond Python 3 and the falla command.
# Python: run falla as a command, then read the candidates.json it writes.
# Usage: python3 examples/read_report.py [video.mp4]
import json
import subprocess
import sys
file = sys.argv[1] if len(sys.argv) > 1 else "fixtures/video/video-dirty.mp4"
out = "out/python-example"
subprocess.run(["falla", "video", file, "--out", out], check=True)
with open(f"{out}/candidates.json", encoding="utf-8") as f:
report = json.load(f)
for c in report["candidates"]:
print(c["id"], c["severity"], c["kind"], c["t"], c["explanation"])
high = [c for c in report["candidates"] if c["severity"] == "high"]
print(f"{len(high)} high-severity candidate(s)")
sys.exit(1 if high else 0)The report
Every run writes candidates.json, already sorted by score, highest first. The fields that matter most are kind, frame and t (where it is), bbox_norm (the region, from 0 to 1), severity, explanation (one line), crop (the image), and confirm, which is empty until Claude has answered.
What it finds
| Kind | Where | What it means |
|---|---|---|
intersects_content | scene | a mesh crosses a photo, splat or poster plane |
occludes_content | scene, video | geometry or an overlay covers part of the content |
z_fighting | scene, video | two coplanar surfaces fight for depth, shown as stripes |
pop_in, pop_out | scene, video | something appears or vanishes in frame, not at a cut |
see_through | scene | an object behind a wall is visible |
camera_inside, near_clip | scene | the camera is inside a closed mesh, or the near plane cuts one |
floating | scene | an object hangs without support |
backfacing_content | scene | a content plane is seen from its back |
flicker | video | local high-frequency flicker that is not z-fighting |
layer_intrusion | video | flat computer-graphics shapes inside a photographic region |
overlay_covers_subject | video | a fixed overlay sits over a moving subject |
Confirming with Claude
Local detectors find the candidates. The web page does not ask Claude. The command line, the library and the MCP server do, with your own ANTHROPIC_API_KEY, and they send only the crops and short notes of a few candidates, never your whole file.
They use claude-haiku-5-5 by default; --model (or model) changes it. falla confirm --dry-run shows the request and its estimated cost without sending it.