more features
This commit is contained in:
1 parent
bd8edf5afb
commit
1530b74d04
10 files changed
+563
-39
No files matched your search
@@ -0,0 +1,265 @@
|
||||
"""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 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()
|
||||
Reference in new issue
Block a user