""" - Test Output classes. - Integration tests (see conftest.py). """ import shutil from fractions import Fraction from pathlib import Path from typing import TYPE_CHECKING import pytest from corrscope.channel import ChannelConfig from corrscope.corrscope import default_config, Config, CorrScope, Arguments from corrscope.outputs import ( RGB_DEPTH, FFmpegOutput, FFmpegOutputConfig, FFplayOutput, FFplayOutputConfig, ) from corrscope.renderer import RendererConfig, MatplotlibRenderer from tests.test_renderer import WIDTH, HEIGHT, ALL_ZEROS if TYPE_CHECKING: import pytest_mock if not shutil.which("ffmpeg"): pytestmark = pytest.mark.skip("Missing ffmpeg, skipping output tests") CFG = default_config(render=RendererConfig(WIDTH, HEIGHT)) NULL_OUTPUT = FFmpegOutputConfig(None, "-f null") def test_render_output(): """ Ensure rendering to output does not raise exceptions. """ renderer = MatplotlibRenderer(CFG.render, CFG.layout, nplots=1, channel_cfgs=None) out: FFmpegOutput = NULL_OUTPUT(CFG) renderer.render_frame([ALL_ZEROS]) out.write_frame(renderer.get_frame()) assert out.close() == 0 def test_output(): out: FFmpegOutput = NULL_OUTPUT(CFG) frame = bytes(WIDTH * HEIGHT * RGB_DEPTH) out.write_frame(frame) assert out.close() == 0 # Ensure video is written to stdout, and not current directory. assert not Path("-").exists() # Ensure CorrScope closes pipe to output upon completion. @pytest.mark.usefixtures("Popen") def test_close_output(Popen): """ FFplayOutput unit test: Ensure ffmpeg and ffplay are terminated when Python exceptions occur. """ ffplay_cfg = FFplayOutputConfig() output: FFplayOutput with ffplay_cfg(CFG) as output: pass output._pipeline[0].stdin.close.assert_called() for popen in output._pipeline: popen.wait.assert_called() # Does wait() need to be called? # Ensure CorrScope terminates FFplay upon exceptions. @pytest.mark.usefixtures("Popen") def test_terminate_ffplay(Popen): """ FFplayOutput unit test: Ensure ffmpeg and ffplay are terminated when Python exceptions occur. """ ffplay_cfg = FFplayOutputConfig() try: output: FFplayOutput with ffplay_cfg(CFG) as output: raise DummyException except DummyException: for popen in output._pipeline: popen.terminate.assert_called() def sine440_config(): cfg = default_config( channels=[ChannelConfig("tests/sine440.wav")], master_audio="tests/sine440.wav", end_time=0.5, # Reduce test duration ) return cfg @pytest.mark.usefixtures("Popen") def test_corr_terminate_ffplay(Popen, mocker: "pytest_mock.MockFixture"): """ Integration test: Ensure corrscope calls terminate() on ffmpeg and ffplay when Python exceptions occur. """ cfg = sine440_config() corr = CorrScope(cfg, Arguments(".", [FFplayOutputConfig()])) render_frame = mocker.patch.object(MatplotlibRenderer, "render_frame") render_frame.side_effect = DummyException() with pytest.raises(DummyException): corr.play() assert len(corr.outputs) == 1 output: FFplayOutput = corr.outputs[0] for popen in output._pipeline: popen.terminate.assert_called() @pytest.mark.skip("Launches ffmpeg and ffplay processes, creating a ffplay window") def test_corr_terminate_works(): """ Ensure that ffmpeg/ffplay terminate quickly after Python exceptions, when `popen.terminate()` is called. """ cfg = sine440_config() corr = CorrScope(cfg, Arguments(".", [FFplayOutputConfig()])) corr.raise_on_teardown = DummyException with pytest.raises(DummyException): # Raises `subprocess.TimeoutExpired` if popen.terminate() doesn't work. corr.play() def test_corr_output_without_audio(): """Ensure running CorrScope with FFmpeg output, with master audio disabled, does not crash. """ cfg = sine440_config() cfg.master_audio = None corr = CorrScope(cfg, Arguments(".", [NULL_OUTPUT])) # Should not raise exception. corr.play() def test_render_subfps_one(): """ Ensure video gets rendered when render_subfps=1. This test fails if ceildiv is used to calculate `ahead`. """ from corrscope.outputs import IOutputConfig, Output, register_output # region DummyOutput class DummyOutputConfig(IOutputConfig): pass @register_output(DummyOutputConfig) class DummyOutput(Output): frames_written = 0 @classmethod def write_frame(cls, frame: bytes) -> None: cls.frames_written += 1 assert DummyOutput # endregion # Create CorrScope with render_subfps=1. Ensure multiple frames are outputted. cfg = sine440_config() cfg.render_subfps = 1 corr = CorrScope(cfg, Arguments(".", [DummyOutputConfig()])) corr.play() assert DummyOutput.frames_written >= 2 def test_render_subfps_non_integer(mocker: "pytest_mock.MockFixture"): """ Ensure we output non-integer subfps as fractions, and that ffmpeg doesn't crash. TODO does ffmpeg understand decimals?? """ cfg = sine440_config() cfg.fps = 60 cfg.render_subfps = 7 # By default, we output render_fps (ffmpeg -framerate) as a fraction. assert isinstance(cfg.render_fps, Fraction) assert cfg.render_fps != int(cfg.render_fps) assert Fraction(1) == int(1) corr = CorrScope(cfg, Arguments(".", [NULL_OUTPUT])) corr.play() # But it seems FFmpeg actually allows decimal -framerate (although a bad idea). # from corrscope.corrscope import Config # render_fps = mocker.patch.object(Config, 'render_fps', # new_callable=mocker.PropertyMock) # render_fps.return_value = 60 / 7 # assert isinstance(cfg.render_fps, float) # corr = CorrScope(cfg, '.', outputs=[NULL_OUTPUT]) # corr.play() # Possibility: add a test to ensure that we render slightly ahead in time # when subfps>1, to avoid frames lagging behind audio. class DummyException(Exception): pass # Tests for Output-dependent performance options def perf_cfg(): """ Return config which reduces rendering workload when previewing. """ cfg = sine440_config() # Skip frames. assert cfg.end_time == 0.5 cfg.render_subfps = 2 # Divide dimensions. cfg.render.width = 192 cfg.render.height = 108 cfg.render.res_divisor = 1.5 return cfg def previews_records(mocker): configs = (Config, RendererConfig) previews = [mocker.spy(cls, "before_preview") for cls in configs] records = [mocker.spy(cls, "before_record") for cls in configs] return previews, records NO_FFMPEG = [[], [FFplayOutputConfig()]] @pytest.mark.usefixtures("Popen") # Prevents FFplayOutput from launching processes. @pytest.mark.parametrize("outputs", NO_FFMPEG) def test_preview_performance(Popen, mocker: "pytest_mock.MockFixture", outputs): """ Ensure performance optimizations enabled if all outputs are FFplay or others. """ get_frame = mocker.spy(MatplotlibRenderer, "get_frame") previews, records = previews_records(mocker) cfg = perf_cfg() corr = CorrScope(cfg, Arguments(".", outputs)) corr.play() # Check that only before_preview() called. for p in previews: p.assert_called() for r in records: r.assert_not_called() # Check renderer is 128x72 assert corr.renderer.cfg.width == 128 assert corr.renderer.cfg.height == 72 # Ensure subfps is enabled (only odd frames are rendered, 1..29). # See CorrScope `should_render` variable. assert ( get_frame.call_count == round(cfg.end_time * cfg.fps / cfg.render_subfps) == 15 ) YES_FFMPEG = [l + [FFmpegOutputConfig(None)] for l in NO_FFMPEG] @pytest.mark.usefixtures("Popen") @pytest.mark.parametrize("outputs", YES_FFMPEG) def test_record_performance(Popen, mocker: "pytest_mock.MockFixture", outputs): """ Ensure performance optimizations disabled if any FFmpegOutputConfig is found. """ get_frame = mocker.spy(MatplotlibRenderer, "get_frame") previews, records = previews_records(mocker) cfg = perf_cfg() corr = CorrScope(cfg, Arguments(".", outputs)) corr.play() # Check that only before_record() called. for p in previews: p.assert_not_called() for r in records: r.assert_called() # Check renderer is 192x108 assert corr.renderer.cfg.width == 192 assert corr.renderer.cfg.height == 108 # Ensure subfps is disabled. assert get_frame.call_count == round(cfg.end_time * cfg.fps) + 1 == 31 # Moved to test_output.py from test_cli.py. # Because test depends on ffmpeg (not available in Appveyor CI), # and only test_output.py (not test_cli.py) is skipped in Appveyor. def test_no_audio(mocker): """ Corrscope Config without master audio should work. """ subdir = "tests" wav = "sine440.wav" channels = [ChannelConfig(wav)] cfg = default_config(master_audio=None, channels=channels, begin_time=100) # begin_time=100 skips all actual rendering. output = FFmpegOutputConfig(None) corr = CorrScope(cfg, Arguments(subdir, [output])) corr.play()