BlenDiff
Semantic diff, snapshot history, and assisted merge for Blender .blend files.
BlenDiff compares two .blend file states using Blender's Python API — not binary diffing — and provides a full assisted merge system. It ships as both a Blender addon and a pip-installable pure-Python library for headless/CI use.
BlenDiff aims to be honest about what it knows: it will not report a change it cannot substantiate, and will not claim to have applied one it did not. Domains an older snapshot never captured are reported as not compared rather than diffed, and merge conflicts BlenDiff cannot write back are shown as read-only rather than soliciting a decision it would discard.
Features
- Snapshot history — named, timestamped snapshots stored in a human-readable
.blendiffJSON sidecar next to your.blendfile, written atomically so a crash can never truncate your history - Rename tracking — objects carry a persistent id, so renaming
CubetoBody_LOWstays one modified object with all its other changes intact, instead of an unrelated delete plus add - Object & collection diffing — detects added, removed, renamed, and modified objects with per-property change tracking (local transform, viewport/render visibility, multi-collection membership)
- Material node graph diffing — per-node, per-socket comparison including image names, input values, and rewired links
- Render settings diffing — engine, resolution, sampling, output format, color management, Cycles and EEVEE sub-settings
- Camera & light diffing — focal length, clip planes, DOF, sensor, light type, energy, shadow, spot/area/sun settings
- Mesh summary diffing — vertex/edge/face counts, bounding box, UV layers, shape keys, vertex groups
- World/environment diffing — background color, strength, HDRI filepath, ambient occlusion
- Modifier stack diffing — ordered comparison of 15+ modifier types with per-param change detection
- Parent/child relationship diffing — parent name, parent type, parent bone (critical for rigs)
- Constraint stack diffing — 25+ constraint types with per-param comparison (IK, Copy Location/Rotation/Scale, Track To, Child Of, and more)
- Custom property diffing — detects added, removed, and changed
obj[key]properties with float tolerance - F-curve diffing — per-channel keyframe count, frame range, interpolation, and extrapolation
- Schema versioning — snapshots record which domains they captured, so diffing across BlenDiff versions never invents changes for a feature that did not exist yet
- Three-way merge — conflict detection across every diff domain, with per-property resolution (Use A / Use B / Use Base) in a Blender UI that marks unappliable differences read-only
- Annotated HTML export — self-contained dark-themed report with per-entry annotation textareas and JSON round-trip
- Headless CLI — run diffs in CI without launching Blender
Installation
As a pip library (no Blender required)
pip install blendiff
As a Blender addon
Download the latest blendiff-0.6.0.zip from Releases and install via Edit → Preferences → Add-ons → Install.
CLI Usage
# List snapshots in a sidecar file
blendiff list scene.blendiff
# Compare two snapshots
blendiff compare scene.blendiff "Before rigging" "After rigging"
# Fail CI if any changes exist
blendiff latest scene.blendiff --fail-on-changes
# Export an HTML report
blendiff compare scene.blendiff "v1" "v2" --output report.html
Python API
from blendiff.storage.sidecar import SidecarManager
from blendiff.diff_engine.diff_engine import DiffEngine
mgr = SidecarManager("scene.blendiff")
snaps = {s.label: s for s in mgr.list_snapshots()}
engine = DiffEngine()
result = engine.compare(snaps["v1"].data, snaps["v2"].data)
# Render settings diff
print(result.render_diff.summary())
# Object diffs
for diff in result.object_diffs:
print(diff.name, diff.kind)
for change in diff.changes:
print(" ", change.property_path, change.old_value, "→", change.new_value)
# Renames — matched by persistent id, not name
for diff in result.renamed_objects:
print(diff.previous_name, "→", diff.name)
# Domains one snapshot never captured, so they were not compared
for note in result.skip_notes:
print("not compared:", note)
# Parent relationship diffs
for diff in result.parent_diffs:
print(diff.summary())
# Constraint diffs
for diff in result.constraint_diffs:
print(diff.summary())
# Custom property diffs
for diff in result.custom_prop_diffs:
print(diff.summary())
# F-curve diffs
for diff in result.fcurve_diffs:
print(diff.summary())
Architecture
blendiff/
├── data_model/ # Dataclasses — SceneDiff, ConstraintDiff, …, plus schema.py
├── diff_engine/ # Pure comparison logic — no bpy
├── serializer/ # mathutils → JSON-safe types
├── storage/ # .blendiff sidecar CRUD + schema migration
├── merge_engine/ # Three-way merge and the applier registry
├── export/ # Shared diff→dict conversion + HTML report generation
├── cli/ # Headless CLI + importable Python API
├── extractor/ # bpy readers (Blender-only)
└── ui/ # Blender panels and operators (Blender-only)
The extractor is the only module that reads from bpy. Everything downstream is pure Python and fully testable without Blender. The merge applier writes to bpy, but only inside individual writer functions that import it locally, so the module stays importable and testable outside Blender.
What merge can and cannot apply
The applier registry (merge_engine/property_appliers.py) is the single source of truth, and can_apply(property_path) answers it directly. The merge UI uses that answer to decide whether to offer a resolution at all, so it never asks you to choose between two values it would then discard.
| Applied automatically | Reported, but reconcile by hand |
|---|---|
| Object name, local transform, rotation mode | Mesh geometry (summarised, not stored) |
| Viewport and render visibility | Modifier and constraint stacks |
| Collection membership | Keyframes, drivers, NLA strips |
| Material slot assignment | Material node graphs |
| Parenting (world position preserved) | Object type, collection hierarchy |
| Custom properties | Object creation (needs the source file) |
| Camera and light data |
Snapshot compatibility
Snapshots record a schema version and the set of domains they captured. A domain captured by only one of two snapshots is reported as not compared rather than diffed — otherwise a snapshot taken before F-curve support existed would make every curve in a newer snapshot look newly added. Older snapshots are migrated to the current schema as they are read; the file on disk is left alone until you call SidecarManager.migrate_file().
Running Tests
pip install blendiff[dev]
# Pure-Python core — no Blender needed
pytest tests/ -v -m "not integration"
# Everything, including the extractor tests that run inside Blender
pytest tests/ -v
900+ unit tests run without Blender. A further 39 integration tests exercise the extractor package against a real Blender, and are skipped automatically when no Blender binary is found. Point BLENDER_BINARY at a specific build to test against a particular version:
BLENDER_BINARY=/opt/blender-4.2/blender pytest tests/integration -v
CI Integration
# .github/workflows/diff.yml
- name: Check for scene changes
run: blendiff latest scene.blendiff --fail-on-changes
See docs/ci_example.yml for a diff-checking workflow template, and .github/workflows/tests.yml for the project's own CI — unit tests across Python 3.10–3.12, integration tests against Blender 4.2 and 5.1, and a check that the published wheel imports without Blender.
License
MIT
Metadata
Release files for blendiff 0.6.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| blendiff-0.6.0.tar.gz | 139.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| blendiff-0.6.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 216.4 kB
Release files / blendiff-0.6.0.tar.gz
| Download URL | blendiff-0.6.0.tar.gz |
|---|---|
| Size | 139.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
43fa517f5cfb27c96db2fdfb998e4612c397c14d8a01bb20a76be29b08a87618
|
|
BLAKE2b-256 checksum How to use checksums |
147b000ea34fdcf1c5318cb80b7cfc07185a80e25556285058d9ddfa3f4e964d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.7 {"installer":{"name":"uv","version":"0.11.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"CachyOS Linux","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / blendiff-0.6.0-py3-none-any.whl
| Download URL | blendiff-0.6.0-py3-none-any.whl |
|---|---|
| Size | 76.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2407704dd3492a4808611d978bbc8e22c3fff78007ad8da4e8e64c8848f870f8
|
|
BLAKE2b-256 checksum How to use checksums |
d9dc5426fda5572d9abdfde611fa6636a9a2a9b5af491991df6ff1380160df72
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.7 {"installer":{"name":"uv","version":"0.11.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"CachyOS Linux","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|