"""Validate basic scene operations without an Unreal installation.""" import importlib.util from pathlib import Path import shutil import subprocess import sys import tempfile import unittest from unittest.mock import MagicMock, patch import wave ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "Content/Python")) sys.path.insert(0, str(ROOT / "tests")) from test_offline import png from ue_workshop.video import encode def load(unreal, filename): path = ROOT / "Content/Python/ue_workshop" / filename spec = importlib.util.spec_from_file_location("ue_workshop._basics_test_" + path.stem, path) module = importlib.util.module_from_spec(spec) with patch.dict(sys.modules, {"unreal": unreal}): spec.loader.exec_module(module) return module def keyed_channel(frames): channel = MagicMock() keys = [] for frame in frames: key = MagicMock() key.get_time.return_value.frame_number.value = frame keys.append(key) channel.get_keys.return_value = keys return channel, keys class SequenceBasicsTests(unittest.TestCase): def setUp(self): self.unreal = MagicMock() self.unreal.FrameNumber.side_effect = lambda frame: frame self.module = load(self.unreal, "sequence.py") self.shot = self.module.Sequence.__new__(self.module.Sequence) self.shot.frames, self.shot.fps = 48, 24 self.shot.asset = MagicMock() self.shot.scene = MagicMock() self.shot.bind = MagicMock(side_effect=lambda actor: actor.binding) self.shot._camera_cuts, self.shot._visibility, self.shot._animations = [], {}, {} self.shot._transforms = {} self.actor = MagicMock() self.actor.get_path_name.return_value = "Actor" self.actor.get_attached_actors.return_value = [] self.channel = self.actor.binding.add_track.return_value.add_section.return_value.get_all_channels.return_value[0] self.channel.get_keys.return_value = [] def cuts(self): return [(start, end) for start, end, _ in self.shot._camera_cuts] def test_later_camera_cuts_the_rest_of_the_sequence(self): self.shot.camera(MagicMock()) self.shot.camera(MagicMock(), start=24) self.assertEqual(self.cuts(), [(0, 24), (24, 48)]) def test_camera_inside_another_splits_it(self): first = MagicMock() self.shot.camera(first) self.shot.camera(MagicMock(), start=12, end=24) self.assertEqual(self.cuts(), [(0, 12), (12, 24), (24, 48)]) self.assertIs(self.shot._camera_cuts[2][2], first.binding) def test_camera_covering_another_replaces_it(self): self.shot.camera(MagicMock(), start=12, end=24) self.shot.camera(MagicMock(), start=0, end=48) self.assertEqual(self.cuts(), [(0, 48)]) def test_rerunning_camera_replaces_track_sections(self): track = self.shot.asset.find_tracks_by_type.return_value[0] track.get_sections.return_value = ["old"] self.shot.camera(MagicMock()) track.remove_section.assert_called_once_with("old") def test_invalid_camera_range_changes_nothing(self): for start, end in [(24, 24), (-1, 10), (0, 49)]: with self.assertRaises(ValueError): self.shot.camera(MagicMock(), start=start, end=end) self.assertEqual(self.shot._camera_cuts, []) def test_first_show_hides_actor_before_that_frame(self): self.shot.visible(self.actor, 12) self.assertEqual([call.args for call in self.channel.add_key.call_args_list], [(0, False), (12, True)]) def test_first_hide_shows_actor_before_that_frame(self): self.shot.visible(self.actor, 12, False) self.assertEqual([call.args for call in self.channel.add_key.call_args_list], [(0, True), (12, False)]) def test_key_on_first_frame_adds_no_opposite_state(self): self.shot.visible(self.actor, 0, False) self.channel.add_key.assert_called_once_with(0, False) def test_visibility_includes_attached_actors_and_reuses_tracks(self): child = MagicMock() child.get_path_name.return_value = "Child" self.actor.get_attached_actors.return_value = [child] self.shot.visible(self.actor, 12) self.shot.visible(self.actor, 24, False) self.actor.get_attached_actors.assert_called_with(True, True) self.actor.binding.add_track.assert_called_once() child.binding.add_track.assert_called_once() def test_same_frame_replaces_visibility_key(self): channel, keys = keyed_channel([12]) self.shot._visibility["Actor"] = channel self.shot.visible(self.actor, 12, False) channel.remove_key.assert_called_once_with(keys[0]) def test_visibility_validation(self): for frame, value in [(48, True), (0, "no")]: with self.assertRaises(ValueError): self.shot.visible(self.actor, frame, value) self.shot.bind.assert_not_called() def transform_channels(self, values): channels = [] for value in values: channel, _ = keyed_channel([]) channel.evaluate_keys.return_value = [value] channels.append(channel) self.shot._transforms["Actor"] = channels return channels def test_rotation_without_track_reads_actor(self): rotation = self.actor.get_actor_rotation.return_value rotation.roll, rotation.pitch, rotation.yaw = 1, 2, 3 self.assertEqual(self.shot.rotation(self.actor, 0), (1, 2, 3)) def test_rotation_reads_rotation_channels(self): self.transform_channels([1, 2, 3, 10, 20, 30, 1, 1, 1]) self.assertEqual(self.shot.rotation(self.actor, 5), (10, 20, 30)) self.assertEqual(self.shot.position(self.actor, 5), (1, 2, 3)) def test_rotate_adds_relative_keys_past_full_turn(self): channels = self.transform_channels([0, 0, 0, 0, 0, 45, 1, 1, 1]) self.assertEqual(self.shot.rotate(self.actor, 0, 47, (0, 0, 360)), (0, 0, 405)) self.assertEqual([call.args[:2] for call in channels[5].add_key.call_args_list], [(0, 45), (47, 405)]) channels[0].add_key.assert_not_called() def test_rotate_replaces_range_and_shifts_later_keys_only_for_rotation(self): channels = self.transform_channels([0, 0, 0, 0, 0, 0, 1, 1, 1]) inside, later = MagicMock(), MagicMock() inside.get_time.return_value.frame_number.value = 10 later.get_time.return_value.frame_number.value = 30 later.get_value.return_value = 5.0 channels[5].get_keys.return_value = [inside, later] position_key = MagicMock() channels[0].get_keys.return_value = [position_key] self.shot.rotate(self.actor, 0, 20, (0, 0, 90)) channels[5].remove_key.assert_called_once_with(inside) later.set_value.assert_called_once_with(95.0) position_key.set_value.assert_not_called() def test_rotate_validation_creates_no_keys(self): channels = self.transform_channels([0] * 9) for start, end, offset in [(10, 10, (0, 0, 1)), (0, 48, (0, 0, 1)), (0, 10, (0, 1))]: with self.assertRaises(ValueError): self.shot.rotate(self.actor, start, end, offset) channels[5].add_key.assert_not_called() def test_seconds_rounds_to_frames(self): self.assertEqual(self.shot.seconds(1.5), 36) self.assertEqual(self.shot.seconds(0.03), 1) with self.assertRaises(ValueError): self.shot.seconds(-1) def test_then_starts_after_latest_clip_with_overlap(self): child = MagicMock() child.get_path_name.return_value = "Child" self.shot.scene._skeletal_models = {"Actor": (child, MagicMock())} self.shot._animations = {"Child": [(0, 24), (24, 30)]} self.shot.animation = MagicMock() self.shot.then(self.actor, "walk", overlap=6, end=48) self.shot.animation.assert_called_once_with(self.actor, "walk", start=24, end=48) def test_then_on_first_clip_starts_at_zero(self): child = MagicMock() self.shot.scene._skeletal_models = {"Actor": (child, MagicMock())} self.shot.animation = MagicMock() self.shot.then(self.actor, "walk") self.shot.animation.assert_called_once_with(self.actor, "walk", start=0) def test_then_requires_skeletal_model(self): self.shot.scene._skeletal_models = {} with self.assertRaises(ValueError): self.shot.then(self.actor, "walk") class MaterialAssignTests(unittest.TestCase): def setUp(self): self.module = load(MagicMock(), "materials.py") def actor(self, *slot_counts, children=()): actor = MagicMock() components = [] for count in slot_counts: component = MagicMock() component.get_num_materials.return_value = count components.append(component) actor.get_components_by_class.return_value = components actor.get_attached_actors.return_value = list(children) return actor, components def test_model_root_without_mesh_reaches_attached_mesh(self): child, [mesh] = self.actor(2) root, [empty] = self.actor(0, children=[child]) self.module.assign(root, "red") empty.set_material.assert_not_called() self.assertEqual([call.args for call in mesh.set_material.call_args_list], [(0, "red"), (1, "red")]) def test_slot_replaces_one_material(self): actor, [mesh] = self.actor(3) self.module.assign(actor, "red", slot=1) mesh.set_material.assert_called_once_with(1, "red") def test_actor_without_any_mesh_is_rejected(self): actor, _ = self.actor(0) with self.assertRaises(TypeError): self.module.assign(actor, "red") class NaniteTests(unittest.TestCase): def setUp(self): self.unreal = MagicMock() self.module = load(self.unreal, "assets.py") self.mesh = MagicMock() self.settings = self.mesh.get_editor_property.return_value self.subsystem = self.unreal.get_editor_subsystem.return_value def test_enabled_nanite_is_disabled_and_saved(self): self.settings.enabled = True self.module._disable_nanite(self.mesh) self.assertFalse(self.settings.enabled) self.subsystem.set_nanite_settings.assert_called_once_with(self.mesh, self.settings, True) self.unreal.EditorAssetLibrary.save_loaded_asset.assert_called_once_with(self.mesh) def test_disabled_mesh_is_not_rebuilt(self): self.settings.enabled = False self.module._disable_nanite(self.mesh) self.subsystem.set_nanite_settings.assert_not_called() class RendererAudioTests(unittest.TestCase): def test_audio_requires_mp4(self): module = load(MagicMock(), "render.py") with self.assertRaises(ValueError): module.Renderer(mp4=False, audio="music.mp3") def test_missing_audio_stops_encoding(self): with tempfile.TemporaryDirectory() as directory: folder = Path(directory) png(folder / "frame_0000.png", (255, 0, 0)) with self.assertRaises(FileNotFoundError): encode(folder, 24, audio=folder / "missing.wav") @unittest.skipUnless(shutil.which("ffmpeg") and shutil.which("ffprobe"), "FFmpeg missing") def test_audio_is_cut_to_film_length(self): with tempfile.TemporaryDirectory() as directory: folder = Path(directory) / "frames" folder.mkdir() for index in range(24): png(folder / f"frame_{index:04d}.png", (255, 0, 0)) music = Path(directory) / "music.wav" with wave.open(str(music), "wb") as target: target.setnchannels(1) target.setsampwidth(2) target.setframerate(8000) target.writeframes(b"\x00\x10" * 8000 * 3) movie = encode(folder, 24, expected_frames=24, audio=music) probe = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "stream=codec_type,duration", "-of", "csv=p=0", str(movie)], capture_output=True, text=True, check=True) streams = dict(line.split(",") for line in probe.stdout.split()) self.assertAlmostEqual(float(streams["video"]), 1.0, places=2) self.assertAlmostEqual(float(streams["audio"]), 1.0, delta=0.1) if __name__ == "__main__": unittest.main()