blender/tests/python/bl_blendfile_autosave.py
Sean Kim 8ecebf0ce7 Image: Add autosave support
This commit adds support to the autosave functionality to handle images
and allows autosave to work in Texture paint.

This requires two main changes:
* Packing all edited & editable images.
* Allow autosave to function when the most recent step is an image undo
  step.

The majority of this PR addresses the first change.

For all images that are to be autosaved, we pack to a new field on the
`Image`, `autosave_packedfiles` and mark the datablock with a new flag,
`IMA_AUTOSAVE_TEMPPACK`.

This field is used to prepopulate the `Image` datablock runtime cache
on file load, which then marks the ImBuf as dirty, so the user will be
informed to either save or discard changes.

If an image is saved, packed, or reloaded, the autosave information
(both the flag and the autosave packed file list) is cleared.
Additionally, the autosave information is cleared when the cache is
populated when loading the file.

As a similar area of concern, we also do this when creating the recovery
file, `quit.blend`

Part of #134518

Pull Request: https://projects.blender.org/blender/blender/pulls/152453
2026-05-21 20:19:54 +02:00

262 lines
9.7 KiB
Python

# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: Apache-2.0
import unittest
# ./blender.bin --background --python tests/python/bl_blendfile_autosave.py -- --output-dir=/tmp/
import bpy
import os
import re
import sys
import tempfile
sys.path.append(os.path.dirname(os.path.realpath(__file__)))
from bl_blendfile_utils import TestHelper
class TestBlendFileAutosave(TestHelper):
def __init__(self, args):
super().__init__(args)
@staticmethod
def find_autosave_path(base_dir, base_filename):
pattern = re.compile("{}_[0-9]+_autosave.blend".format(base_filename))
paths = os.listdir(base_dir)
for path in paths:
if pattern.match(path):
return os.path.join(base_dir, path)
return None
def test_autosave_restore(self):
bpy.ops.wm.read_homefile(use_empty=True, use_factory_startup=True)
orig_mesh = bpy.data.meshes.new("OrigCubeMesh")
orig_mesh.use_fake_user = True
output_dir = self.args.output_dir
self.ensure_path(output_dir)
bpy.context.preferences.filepaths.temporary_directory = output_dir
output_path = os.path.join(output_dir, "blendfile_autosave.blend")
orig_data = self.blender_data_to_tuple(bpy.data, "orig_data")
bpy.ops.wm.save_as_mainfile(filepath=output_path, check_existing=False, compress=False)
new_mesh = bpy.data.meshes.new("NewCubeMesh")
new_mesh.use_fake_user = True
autosave_data = self.blender_data_to_tuple(bpy.data, "autosave_data")
bpy.ops.wm.save_auto_save()
bpy.ops.wm.open_mainfile(filepath=output_path, load_ui=False)
read_data = self.blender_data_to_tuple(bpy.data, "read_data")
self.assertEqual(read_data, orig_data)
self.assertNotEqual(read_data, autosave_data)
self.assertIn("OrigCubeMesh", bpy.data.meshes)
self.assertNotIn("NewCubeMesh", bpy.data.meshes)
autosave_path = self.find_autosave_path(output_dir, "blendfile_autosave")
self.assertTrue(autosave_path)
self.assertNotEqual(output_path, autosave_path)
retval = bpy.ops.wm.recover_auto_save(filepath=autosave_path)
self.assertEqual(retval, {'FINISHED'})
recover_data = self.blender_data_to_tuple(bpy.data, "recover_data")
self.assertEqual(recover_data, autosave_data)
self.assertIn("OrigCubeMesh", bpy.data.meshes)
self.assertIn("NewCubeMesh", bpy.data.meshes)
class TestBlendFileImageAutosave(TestHelper):
def __init__(self, args):
super().__init__(args)
def setUpClass(self):
self.ensure_path(self.args.output_dir)
self.temp_dir = tempfile.TemporaryDirectory(dir=self.args.output_dir)
def tearDownClass(self):
self.temp_dir.cleanup()
@staticmethod
def find_autosave_path(base_dir, base_filename):
pattern = re.compile("{}_[0-9]+_autosave.blend".format(base_filename))
paths = os.listdir(base_dir)
for path in paths:
if pattern.match(path):
return os.path.join(base_dir, path)
return None
@staticmethod
def modify_image(image):
width, height = image.size
color = (1.0, 1.0, 1.0, 1.0)
pixels = width * height
for pixel in range(pixels):
for channel in range(4):
image.pixels[pixel * 4 + channel] = color[channel]
def check_image(self, image, expected_color):
width, height = image.size
pixels = width * height
actual_data = list(image.pixels[:])
expected_data = [0.0] * pixels * 4
for pixel in range(pixels):
for channel in range(4):
expected_data[pixel * 4 + channel] = expected_color[channel]
self.assertEqual(actual_data, expected_data)
def test_generated_image_restore(self):
self.ensure_path(self.args.output_dir)
with tempfile.TemporaryDirectory(dir=self.args.output_dir) as output_dir:
bpy.context.preferences.filepaths.temporary_directory = output_dir
output_path = os.path.join(output_dir, "blendfile_autosave_generated_image.blend")
# Immediately create the file so we can ensure we're working on a
# "generated" and not packed image with autosave
bpy.ops.wm.save_as_mainfile(filepath=output_path, check_existing=False, compress=False)
orig_image = bpy.data.images.new("GeneratedImage", 2, 2, alpha=True)
orig_image.use_fake_user = True
self.modify_image(orig_image)
self.check_image(orig_image, (1.0, 1.0, 1.0, 1.0))
bpy.ops.wm.save_auto_save()
bpy.ops.wm.open_mainfile(filepath=output_path, load_ui=False)
# File should not have image, only autosave should have image
self.assertNotIn("GeneratedImage", bpy.data.images)
autosave_path = self.find_autosave_path(output_dir, "blendfile_autosave_generated_image")
self.assertTrue(autosave_path)
self.assertNotEqual(output_path, autosave_path)
retval = bpy.ops.wm.recover_auto_save(filepath=autosave_path)
self.assertEqual(retval, {'FINISHED'})
self.assertIn("GeneratedImage", bpy.data.images)
self.check_image(bpy.data.images["GeneratedImage"], (1.0, 1.0, 1.0, 1.0))
bpy.ops.wm.open_mainfile(filepath=output_path, load_ui=False)
self.assertNotIn("GeneratedImage", bpy.data.images)
def test_packed_image_restore(self):
self.ensure_path(self.args.output_dir)
with tempfile.TemporaryDirectory(dir=self.args.output_dir) as output_dir:
bpy.context.preferences.filepaths.temporary_directory = output_dir
input_blendfile_path = os.path.join(self.args.src_test_dir, "autosave_image_test.blend")
bpy.ops.wm.open_mainfile(filepath=input_blendfile_path)
output_path = os.path.join(output_dir, "blendfile_autosave_packed_image.blend")
bpy.ops.wm.save_as_mainfile(filepath=output_path, check_existing=False, compress=False)
self.modify_image(bpy.data.images["PackedImage"])
self.check_image(bpy.data.images["PackedImage"], (1.0, 1.0, 1.0, 1.0))
bpy.ops.wm.save_auto_save()
bpy.ops.wm.open_mainfile(filepath=output_path, load_ui=False)
self.assertIn("PackedImage", bpy.data.images)
self.check_image(bpy.data.images["PackedImage"], (0.0, 0.0, 0.0, 1.0))
autosave_path = self.find_autosave_path(output_dir, "blendfile_autosave_packed_image")
self.assertTrue(autosave_path)
self.assertNotEqual(output_path, autosave_path)
retval = bpy.ops.wm.recover_auto_save(filepath=autosave_path)
self.assertEqual(retval, {'FINISHED'})
# Loading the autosave file should show the "temporary" changes
self.assertIn("PackedImage", bpy.data.images)
self.check_image(bpy.data.images["PackedImage"], (1.0, 1.0, 1.0, 1.0))
def test_external_image_restore(self):
self.ensure_path(self.args.output_dir)
with tempfile.TemporaryDirectory(dir=self.args.output_dir) as output_dir:
bpy.context.preferences.filepaths.temporary_directory = output_dir
input_blendfile_path = os.path.join(self.args.src_test_dir, "autosave_image_test.blend")
bpy.ops.wm.open_mainfile(filepath=input_blendfile_path)
output_path = os.path.join(output_dir, "blendfile_autosave_external_image.blend")
bpy.ops.wm.save_as_mainfile(filepath=output_path, check_existing=False, compress=False)
self.modify_image(bpy.data.images["ExternalImage"])
self.check_image(bpy.data.images["ExternalImage"], (1.0, 1.0, 1.0, 1.0))
bpy.ops.wm.save_auto_save()
bpy.ops.wm.open_mainfile(filepath=output_path, load_ui=False)
self.assertIn("ExternalImage", bpy.data.images)
self.check_image(bpy.data.images["ExternalImage"], (0.0, 0.0, 0.0, 1.0))
autosave_path = self.find_autosave_path(output_dir, "blendfile_autosave_external_image")
self.assertTrue(autosave_path)
self.assertNotEqual(output_path, autosave_path)
retval = bpy.ops.wm.recover_auto_save(filepath=autosave_path)
self.assertEqual(retval, {'FINISHED'})
# Loading the autosave file should show the "temporary" changes
self.assertIn("ExternalImage", bpy.data.images)
self.check_image(bpy.data.images["ExternalImage"], (1.0, 1.0, 1.0, 1.0))
# Loading the actual file should show the original data
bpy.data.images["ExternalImage"].reload()
self.check_image(bpy.data.images["ExternalImage"], (0.0, 0.0, 0.0, 1.0))
return
TESTS = (
TestBlendFileAutosave,
TestBlendFileImageAutosave
)
def argparse_create():
import argparse
# When --help or no args are given, print this help
description = "Test basic IO of autosaving a .blend file."
parser = argparse.ArgumentParser(description=description)
parser.add_argument(
"--src-test-dir",
dest="src_test_dir",
help="Root tests directory to search for blendfiles",
required=True,
)
parser.add_argument(
"--output-dir",
dest="output_dir",
help="Where to output temp saved files",
required=True,
)
return parser
def main():
args = argparse_create().parse_args()
# Don't write thumbnails into the home directory.
bpy.context.preferences.filepaths.file_preview_type = 'NONE'
for Test in TESTS:
Test(args).run_all_tests()
if __name__ == '__main__':
import sys
sys.argv = [__file__] + (sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [])
main()