Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions common/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,25 @@

LOG_COMPRESSION_LEVEL = 10 # little benefit up to level 15. level ~17 is a small step change

class Timer:
"""Simple lap timer for profiling sequential operations."""

def __init__(self):
self._start = self._lap = time.monotonic()
self._sections = {}

def lap(self, name):
now = time.monotonic()
self._sections[name] = now - self._lap
self._lap = now

@property
def total(self):
return time.monotonic() - self._start

def fmt(self, duration):
parts = ", ".join(f"{k}={v:.2f}s" + (f" ({duration/v:.0f}x)" if k == 'render' else "") for k, v in self._sections.items())
return f"{duration}s in {self.total:.1f}s ({duration/self.total:.1f}x realtime) | {parts}"

class CallbackReader:
"""Wraps a file, but overrides the read method to also
Expand Down
54 changes: 46 additions & 8 deletions system/ui/lib/application.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import atexit
import cffi
import os
import queue
import time
import signal
import sys
Expand Down Expand Up @@ -40,6 +41,9 @@
PROFILE_STATS = int(os.getenv("PROFILE_STATS", "100")) # Number of functions to show in profile output
RECORD = os.getenv("RECORD") == "1"
RECORD_OUTPUT = str(Path(os.getenv("RECORD_OUTPUT", "output")).with_suffix(".mp4"))
RECORD_BITRATE = os.getenv("RECORD_BITRATE", "") # Target bitrate e.g. "2000k"
RECORD_SPEED = int(os.getenv("RECORD_SPEED", "1")) # Speed multiplier
OFFSCREEN = os.getenv("OFFSCREEN") == "1" # Disable FPS limiting for fast offline rendering

GL_VERSION = """
#version 300 es
Expand Down Expand Up @@ -212,6 +216,9 @@ def __init__(self, width: int | None = None, height: int | None = None):
self._render_texture: rl.RenderTexture | None = None
self._burn_in_shader: rl.Shader | None = None
self._ffmpeg_proc: subprocess.Popen | None = None
self._ffmpeg_queue: queue.Queue | None = None
self._ffmpeg_thread: threading.Thread | None = None
self._ffmpeg_stop_event: threading.Event | None = None
self._textures: dict[str, rl.Texture] = {}
self._target_fps: int = _DEFAULT_FPS
self._last_fps_log_time: float = time.monotonic()
Expand Down Expand Up @@ -276,25 +283,36 @@ def _close(sig, frame):
rl.set_texture_filter(self._render_texture.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)

if RECORD:
output_fps = fps * RECORD_SPEED
ffmpeg_args = [
'ffmpeg',
'-v', 'warning', # Reduce ffmpeg log spam
'-stats', # Show encoding progress
'-nostats', # Suppress encoding progress
'-f', 'rawvideo', # Input format
'-pix_fmt', 'rgba', # Input pixel format
'-s', f'{self._width}x{self._height}', # Input resolution
'-r', str(fps), # Input frame rate
'-i', 'pipe:0', # Input from stdin
'-vf', 'vflip,format=yuv420p', # Flip vertically and convert rgba to yuv420p
'-c:v', 'libx264', # Video codec
'-preset', 'ultrafast', # Encoding speed
'-vf', 'vflip,format=yuv420p', # Flip vertically and convert to yuv420p
'-r', str(output_fps), # Output frame rate (for speed multiplier)
'-c:v', 'libx264',
'-preset', 'ultrafast',
]
if RECORD_BITRATE:
ffmpeg_args += ['-b:v', RECORD_BITRATE, '-maxrate', RECORD_BITRATE, '-bufsize', RECORD_BITRATE]
ffmpeg_args += [
'-y', # Overwrite existing file
'-f', 'mp4', # Output format
RECORD_OUTPUT, # Output file path
]
self._ffmpeg_proc = subprocess.Popen(ffmpeg_args, stdin=subprocess.PIPE)
self._ffmpeg_queue = queue.Queue(maxsize=60) # Buffer up to 60 frames
self._ffmpeg_stop_event = threading.Event()
self._ffmpeg_thread = threading.Thread(target=self._ffmpeg_writer_thread, daemon=True)
self._ffmpeg_thread.start()

rl.set_target_fps(fps)
# OFFSCREEN disables FPS limiting for fast offline rendering (e.g. clips)
rl.set_target_fps(0 if OFFSCREEN else fps)

self._target_fps = fps
self._set_styles()
Expand Down Expand Up @@ -336,6 +354,21 @@ def _startup_profile_context(self):
print(f"{green}UI window ready in {elapsed_ms:.1f} ms{reset}")
sys.exit(0)

def _ffmpeg_writer_thread(self):
"""Background thread that writes frames to ffmpeg."""
while True:
try:
data = self._ffmpeg_queue.get(timeout=1.0)
if data is None: # Sentinel to stop
break
self._ffmpeg_proc.stdin.write(data)
except queue.Empty:
if self._ffmpeg_stop_event.is_set():
break
continue
except Exception:
break

def set_modal_overlay(self, overlay, callback: Callable | None = None):
if self._modal_overlay.overlay is not None:
if hasattr(self._modal_overlay.overlay, 'hide_event'):
Expand Down Expand Up @@ -408,11 +441,17 @@ def _load_texture_from_image(self, image: rl.Image) -> rl.Texture:
return texture

def close_ffmpeg(self):
if self._ffmpeg_thread is not None:
# Send sentinel to signal end of frames, let thread drain queue
self._ffmpeg_queue.put(None)
self._ffmpeg_thread.join(timeout=30)
self._ffmpeg_stop_event.set()

if self._ffmpeg_proc is not None:
self._ffmpeg_proc.stdin.flush()
self._ffmpeg_proc.stdin.close()
try:
self._ffmpeg_proc.wait(timeout=5)
self._ffmpeg_proc.wait(timeout=30)
except subprocess.TimeoutExpired:
self._ffmpeg_proc.terminate()
self._ffmpeg_proc.wait()
Expand Down Expand Up @@ -524,8 +563,7 @@ def render(self):
image = rl.load_image_from_texture(self._render_texture.texture)
data_size = image.width * image.height * 4
data = bytes(rl.ffi.buffer(image.data, data_size))
self._ffmpeg_proc.stdin.write(data)
self._ffmpeg_proc.stdin.flush()
self._ffmpeg_queue.put(data) # Async write via background thread
rl.unload_image(image)

self._monitor_fps()
Expand Down
Loading