Core: extract blendfile_header.py as common utility for parsing .blend files

This new file can parse the file header (first few bytes) as well as the block
headers.

Right now, this is used by two places:
* `blendfile.py` which is used by `blend2json.py`
* `blend_render_info.py`

This new module is shipped with Blender because it's needed for
`blend_render_info.py` which is shipped with Blender too. This makes using it in
`blendfile.py` (which is not shipped with Blender) a bit more annoying. However,
this is already not ideal, because e.g. `blend2json` also has to add to
`sys.path` already to be able to import `blendfile.py`.

This new file could also be used by blender-asset-tracer (BAT).

The new `BlendFileHeader` and `BlockHeader` types may be subclassed by code
using it, because it wants to store additional derived data (`blendfile.py` and
BAT need this).

New tests have been added that check that the file and block header is parsed
correctly for different kinds of .blend files.

Pull Request: https://projects.blender.org/blender/blender/pulls/140341
This commit is contained in:
Jacques Lucke
2025-06-23 12:53:55 +02:00
parent a5399af388
commit f0c7e52ff2
10 changed files with 468 additions and 220 deletions

View File

@@ -340,6 +340,12 @@ if(TEST_SRC_DIR_EXISTS)
--output-dir ${TEST_OUT_DIR}/blendfile_io/
--test-dir "${TEST_SRC_DIR}/libraries_and_linking"
)
add_blender_test(
blendfile_header
--python ${CMAKE_CURRENT_LIST_DIR}/bl_blendfile_header.py --
--testdir "${TEST_SRC_DIR}/io_tests/blend_parsing"
)
endif()
# ------------------------------------------------------------------------------

View File

@@ -0,0 +1,168 @@
# SPDX-FileCopyrightText: 2025 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
import blendfile_header
import blend_render_info
import bpy
import pathlib
import sys
import unittest
import gzip
import tempfile
args = None
class BlendFileHeaderTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.testdir = args.testdir
def setUp(self):
self.assertTrue(self.testdir.exists(),
"Test dir {0} should exist".format(self.testdir))
def test_small_bhead_8(self):
path = self.testdir / "SmallBHead8.blend"
with gzip.open(path, "rb") as f:
header = blendfile_header.BlendFileHeader(f)
self.assertEqual(header.magic, b"BLENDER")
self.assertEqual(header.file_format_version, 0)
self.assertEqual(header.pointer_size, 8)
self.assertTrue(header.is_little_endian)
self.assertEqual(header.version, 300)
header_struct = header.create_block_header_struct()
self.assertIs(header_struct.type, blendfile_header.SmallBHead8)
buffer = f.read(header_struct.struct.size)
block = header_struct.parse(buffer)
self.assertEqual(block.code, b"REND")
self.assertEqual(block.len, 72)
self.assertEqual(block.old, 140732920740000)
self.assertEqual(block.SDNAnr, 0)
self.assertEqual(block.nr, 1)
self.assertEqual(blend_render_info.read_blend_rend_chunk(path), [(1, 250, "Scene")])
def test_large_bhead_8(self):
path = self.testdir / "LargeBHead8.blend"
with open(path, "rb") as f:
header = blendfile_header.BlendFileHeader(f)
self.assertEqual(header.magic, b"BLENDER")
self.assertEqual(header.file_format_version, 1)
self.assertEqual(header.pointer_size, 8)
self.assertTrue(header.is_little_endian)
self.assertEqual(header.version, 500)
header_struct = header.create_block_header_struct()
self.assertIs(header_struct.type, blendfile_header.LargeBHead8)
buffer = f.read(header_struct.struct.size)
block = header_struct.parse(buffer)
self.assertEqual(block.code, b"REND")
self.assertEqual(block.len, 72)
self.assertEqual(block.old, 140737488337232)
self.assertEqual(block.SDNAnr, 0)
self.assertEqual(block.nr, 1)
self.assertEqual(blend_render_info.read_blend_rend_chunk(path), [(1, 250, "Scene")])
def test_bhead_4(self):
path = self.testdir / "BHead4.blend"
with gzip.open(path, "rb") as f:
header = blendfile_header.BlendFileHeader(f)
self.assertEqual(header.magic, b"BLENDER")
self.assertEqual(header.file_format_version, 0)
self.assertEqual(header.pointer_size, 4)
self.assertTrue(header.is_little_endian)
self.assertEqual(header.version, 260)
header_struct = header.create_block_header_struct()
self.assertIs(header_struct.type, blendfile_header.BHead4)
buffer = f.read(header_struct.struct.size)
block = header_struct.parse(buffer)
self.assertEqual(block.code, b"REND")
self.assertEqual(block.len, 32)
self.assertEqual(block.old, 2684488)
self.assertEqual(block.SDNAnr, 0)
self.assertEqual(block.nr, 1)
self.assertEqual(blend_render_info.read_blend_rend_chunk(path), [(1, 250, "Space types")])
def test_bhead_4_big_endian(self):
path = self.testdir / "BHead4_big_endian.blend"
with gzip.open(path, "rb") as f:
header = blendfile_header.BlendFileHeader(f)
self.assertEqual(header.magic, b"BLENDER")
self.assertEqual(header.file_format_version, 0)
self.assertEqual(header.pointer_size, 4)
self.assertFalse(header.is_little_endian)
self.assertEqual(header.version, 170)
header_struct = header.create_block_header_struct()
self.assertIs(header_struct.type, blendfile_header.BHead4)
buffer = f.read(header_struct.struct.size)
block = header_struct.parse(buffer)
self.assertEqual(block.code, b"REND")
self.assertEqual(block.len, 32)
self.assertEqual(block.old, 2147428916)
self.assertEqual(block.SDNAnr, 0)
self.assertEqual(block.nr, 1)
self.assertEqual(blend_render_info.read_blend_rend_chunk(path), [(1, 150, "1")])
def test_current(self):
directory = tempfile.mkdtemp()
path = pathlib.Path(directory) / "test.blend"
bpy.ops.wm.read_factory_settings(use_empty=True)
scene = bpy.data.scenes[0]
scene.name = "Test Scene"
scene.frame_start = 10
scene.frame_end = 20
bpy.ops.wm.save_as_mainfile(filepath=str(path), compress=False, copy=True)
version = bpy.app.version
version_int = version[0] * 100 + version[1]
with open(path, "rb") as f:
header = blendfile_header.BlendFileHeader(f)
self.assertEqual(header.magic, b"BLENDER")
self.assertEqual(header.file_format_version, 1)
self.assertEqual(header.pointer_size, 8)
self.assertTrue(header.is_little_endian)
self.assertEqual(header.version, version_int)
header_struct = header.create_block_header_struct()
self.assertIs(header_struct.type, blendfile_header.LargeBHead8)
buffer = f.read(header_struct.struct.size)
block = header_struct.parse(buffer)
self.assertEqual(block.code, b"REND")
self.assertEqual(blend_render_info.read_blend_rend_chunk(path), [(10, 20, "Test Scene")])
def main():
global args
import argparse
if '--' in sys.argv:
argv = [sys.argv[0]] + sys.argv[sys.argv.index('--') + 1:]
else:
argv = sys.argv
parser = argparse.ArgumentParser()
parser.add_argument('--testdir', required=True, type=pathlib.Path)
args, remaining = parser.parse_known_args(argv)
unittest.main(argv=remaining)
if __name__ == "__main__":
main()

View File

@@ -272,6 +272,12 @@ class TestBlendFileOpenLinkSaveAllTestFiles(TestHelper):
(OSError, RuntimeError),
"created by a Big Endian version of Blender, support for these files has been removed in Blender 5.0"
),
# io_tests/blend_parsing/BHead4_big_endian.blend
# File generated from a big endian build of Blender.
"BHead4_big_endian.blend": (
(OSError, RuntimeError),
"created by a Big Endian version of Blender, support for these files has been removed in Blender 5.0"
),
}
assert all(p.endswith("/") for p in self.excluded_open_link_dirs)