Skip to content

Commit

Permalink
Merge branch 'add-ui-customization' of https://github.com/DominicWind…
Browse files Browse the repository at this point in the history
…isch/photobooth-app into add-ui-customization
  • Loading branch information
DominicWindisch committed Feb 6, 2024
2 parents 0dd1f3c + 51de538 commit 3014fe8
Show file tree
Hide file tree
Showing 30 changed files with 665 additions and 232 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
steps:
- uses: actions/checkout@v4

- uses: pdm-project/setup-pdm@v3
- uses: pdm-project/setup-pdm@v4

- name: Build packages
run: pdm build
Expand Down
12 changes: 7 additions & 5 deletions .github/workflows/pytests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ jobs:
run: |
sudo apt update
sudo apt -y install libturbojpeg python3-pip libgl1 git libcap-dev
sudo apt -y install ffmpeg
- name: install pdm
run: |
pipx install pdm # on hosted pipx is installed
Expand All @@ -54,9 +55,9 @@ jobs:
run: |
cat phpd.log
cat extras/shareservice/php-error.log
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
with:
name: coverage
name: coverage-${{ matrix.python-version }}
path: coverage.xml

tests-hardware-rpi:
Expand All @@ -77,14 +78,15 @@ jobs:
run: |
sudo apt update
sudo apt -y install libturbojpeg0 python3-pip libgl1 libgphoto2-dev pipx
sudo apt -y install ffmpeg
- run: pipx install pdm
- run: pipx ensurepath
- run: pdm venv create --force 3.11 --system-site-packages # incl system site to allow for picamera2 to import
- run: pdm install # install in-project env
- name: Test with pytest
run: |
pdm run test
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
with:
name: coverage-hardware-rpi
path: ./coverage.xml
Expand All @@ -97,6 +99,6 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Download artifacts
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4
- name: Upload to Codecov
uses: codecov/codecov-action@v3
uses: codecov/codecov-action@v4
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@

# ignore userdata folders
# ignore user folders
/config
/data
/media
/log
log/*.log*
/userdata
/tmp

# python files
__pycache__
Expand Down
244 changes: 133 additions & 111 deletions pdm.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions photobooth/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def _create_basic_folders():
os.makedirs("userdata", exist_ok=True)
os.makedirs("log", exist_ok=True)
os.makedirs("config", exist_ok=True)
os.makedirs("tmp", exist_ok=True)

# guard to start only one instance at a time.
try:
Expand Down
2 changes: 1 addition & 1 deletion photobooth/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.2.1"
__version__ = "2.0a1"
5 changes: 5 additions & 0 deletions photobooth/routers/processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ def api_chose_animation_get():
return _capture(container.processing_service.start_job_animation)


@processing_router.get("/chose/video")
def api_chose_video_get():
return _capture(container.processing_service.start_job_video)


@processing_router.get("/cmd/confirm")
def api_cmd_confirm_get():
try:
Expand Down
24 changes: 18 additions & 6 deletions photobooth/services/aquisitionservice.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,18 +103,21 @@ def stats(self):

return aquisition_stats

def _get_video_backend(self) -> AbstractBackend:
if self._is_real_backend(self._live_backend):
logger.info("video requested from dedicated live backend")
return self._live_backend
else:
logger.info("video requested from main backend")
return self._main_backend

def gen_stream(self):
"""
assigns a backend to generate a stream
"""

if appconfig.backends.LIVEPREVIEW_ENABLED:
if self._is_real_backend(self._live_backend):
logger.info("livestream requested from dedicated live backend")
return self._get_stream_from_backend(self._live_backend)
else:
logger.info("livestream requested from main backend")
return self._get_stream_from_backend(self._main_backend)
return self._get_stream_from_backend(self._get_video_backend())

raise ConnectionRefusedError("livepreview not enabled")

Expand All @@ -131,6 +134,15 @@ def wait_for_hq_image(self):

return image_bytes

def start_recording(self):
self._get_video_backend().start_recording()

def stop_recording(self):
self._get_video_backend().stop_recording()

def get_recorded_video(self):
return self._get_video_backend().get_recorded_video()

def signalbackend_configure_optimized_for_hq_capture(self):
"""set backends to capture mode (usually automatically switched as needed by processingservice)"""
if self._main_backend:
Expand Down
109 changes: 109 additions & 0 deletions photobooth/services/backends/abstractbackend.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@
import logging
import os
import time
import uuid
from abc import ABC, abstractmethod
from enum import Enum, auto
from multiprocessing import Condition, Lock, shared_memory
from pathlib import Path
from subprocess import PIPE, Popen

from ...utils.stoppablethread import StoppableThread
from ..config import appconfig

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -72,6 +76,13 @@ def __init__(self):
self._failing_wait_for_lores_image_is_error: bool = False
self._connect_thread: StoppableThread = None

# video feature
self._video_worker_thread: StoppableThread = None
self._video_recorded_videofilepath: Path = None

# services are responsible to create their folders needed for proper processing:
os.makedirs("tmp", exist_ok=True)

super().__init__()

def __repr__(self):
Expand Down Expand Up @@ -306,6 +317,104 @@ def wait_for_lores_image(self, retries: int = 20):

raise RuntimeError("device raised exception") from exc

def start_recording(self):
self._video_worker_thread = StoppableThread(name="_videoworker_fun", target=self._videoworker_fun, daemon=False)
self._video_worker_thread.start()
logger.info("_video_worker_thread started")

def stop_recording(self):
if self._video_worker_thread:
self._video_worker_thread.stop()
self._video_worker_thread.join()
logger.info("_video_worker_thread stopped and joined")

else:
logger.info("no _video_worker_thread active that could be stopped")

def get_recorded_video(self) -> Path:
# basic idea from https://stackoverflow.com/a/42602576
if self._video_recorded_videofilepath is not None:
return self._video_recorded_videofilepath
else:
raise FileNotFoundError("no recorded video avail! if start_recording was called, maybe capture video failed? pls check logs")

def _videoworker_fun(self):
# init worker, set output to None which indicates there is no current video available to get
self._video_recorded_videofilepath = None

# generate temp filename to record to
mp4_output_filepath = Path("tmp", f"{self.__class__.__name__}_{uuid.uuid4().hex}").with_suffix(".mp4")

ffmpeg_subprocess = Popen(
[
"ffmpeg",
"-use_wallclock_as_timestamps",
"1",
"-loglevel",
"info",
"-y",
"-f",
"image2pipe",
"-vcodec",
"mjpeg",
"-i",
"-",
"-vcodec",
"libx264", # warning! image height must be divisible by 2! #there are also hw encoder avail: https://stackoverflow.com/questions/50693934/different-h264-encoders-in-ffmpeg
"-preset",
"veryfast",
"-b:v", # bitrate
f"{appconfig.misc.video_bitrate}k",
"-movflags",
"+faststart",
str(mp4_output_filepath),
],
stdin=PIPE,
stderr=PIPE,
)

logger.info("writing to ffmpeg stdin")
tms = time.time()

while not self._video_worker_thread.stopped():
try:
ffmpeg_subprocess.stdin.write(self._wait_for_lores_image())
ffmpeg_subprocess.stdin.flush() # forces every frame to get timestamped individually
except Exception as exc: # presumably a BrokenPipeError? should we check explicitly?
ffmpeg_subprocess = None
logger.exception(exc)
logger.error(f"Failed to create video! Error: {exc}")

self._video_worker_thread.stop()
break

if ffmpeg_subprocess is not None:
logger.info("writing to ffmpeg stdin finished")
logger.debug(f"-- process time: {round((time.time() - tms), 2)}s ")

# release final video processing
tms = time.time()

_, ffmpeg_stderr = ffmpeg_subprocess.communicate() # send empty to stdin, indicates close and gets stderr/stdout; shut down tidily
code = ffmpeg_subprocess.wait() # Give it a moment to flush out video frames, but after that make sure we terminate it.

if code != 0:
logger.error(ffmpeg_stderr) # can help to track down errors for non-zero exitcodes.

# more debug info can be received in ffmpeg popen stderr (pytest captures automatically)
# TODO: check how to get in application at runtime to write to logs or maybe let ffmpeg write separate logfile
logger.error(f"error creating videofile, ffmpeg exit code ({code}).")

ffmpeg_subprocess = None

logger.info("ffmpeg finished")
logger.debug(f"-- process time: {round((time.time() - tms), 2)}s ")

self._video_recorded_videofilepath = mp4_output_filepath
logger.info(f"record written to {self._video_recorded_videofilepath}")

logger.info("leaving _videoworker_fun")

#
# ABSTRACT METHODS TO BE IMPLEMENTED BY CONCRETE BACKEND (cv2, v4l, ...)
#
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
36 changes: 34 additions & 2 deletions photobooth/services/backends/picamera2.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
import dataclasses
import io
import logging
import uuid
from pathlib import Path
from threading import Condition, Event

from libcamera import Transform, controls # type: ignore
from picamera2 import Picamera2 # type: ignore
from picamera2.encoders import MJPEGEncoder, Quality # type: ignore
from picamera2.outputs import FileOutput # type: ignore
from picamera2.encoders import H264Encoder, MJPEGEncoder, Quality # type: ignore
from picamera2.outputs import FfmpegOutput, FileOutput # type: ignore

from ...utils.stoppablethread import StoppableThread
from ..config import appconfig
Expand Down Expand Up @@ -68,6 +70,11 @@ def __init__(self):
self._lores_data: __class__.PicamLoresData = None
self._hires_data: __class__.PicamHiresData = None

# video related variables. picamera2 uses local recording implementation and overrides abstractbackend
self._video_recorded_videofilepath = None
self._video_encoder = None
self._video_output = None

# worker threads
self._worker_thread: StoppableThread = None

Expand Down Expand Up @@ -236,6 +243,31 @@ def _wait_for_lores_image(self):

return self._lores_data.frame

def start_recording(self):
self._video_recorded_videofilepath = Path("tmp", f"{self.__class__.__name__}_{uuid.uuid4().hex}").with_suffix(".mp4")
self._video_encoder = H264Encoder(appconfig.misc.video_bitrate * 1000) # bitrate in k in appconfig, so *1000
self._video_output = FfmpegOutput(str(self._video_recorded_videofilepath))

self._picamera2.start_encoder(self._video_encoder, self._video_output, name="lores")

logger.info("picamera2 video encoder started")

def stop_recording(self):
if self._video_encoder and self._video_encoder.running:
self._picamera2.stop_encoder(self._video_encoder)
logger.info("picamera2 video encoder stopped")
else:
logger.info("no picamera2 video encoder active that could be stopped")

def get_recorded_video(self) -> Path:
if self._video_recorded_videofilepath is not None:
out = self._video_recorded_videofilepath
self._video_recorded_videofilepath = None

return out
else:
raise FileNotFoundError("no recorded video avail! if start_recording was called, maybe capture video failed? pls check logs")

def _on_configure_optimized_for_hq_capture(self):
logger.debug("change to capture mode requested")
self._last_config = self._current_config
Expand Down
6 changes: 4 additions & 2 deletions photobooth/services/backends/virtualcamera.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from .abstractbackend import AbstractBackend, compile_buffer, decompile_buffer

SHARED_MEMORY_BUFFER_BYTES = 1 * 1024**2
FPS_TARGET = 15

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -54,6 +55,7 @@ def _device_start(self):
self._img_buffer_lock,
self._event_proc_shutdown,
appconfig.uisettings.livestream_mirror_effect,
FPS_TARGET,
),
daemon=True,
)
Expand Down Expand Up @@ -130,6 +132,7 @@ def img_aquisition(
_img_buffer_lock: Lock,
_event_proc_shutdown: Event,
_mirror: bool,
_fps_target: int,
):
"""function started in separate process to deliver images"""

Expand All @@ -141,7 +144,6 @@ def img_aquisition(

logger.info("img_aquisition process started")

target_fps = 15
last_time = time.time_ns()
shm = shared_memory.SharedMemory(shm_buffer_name)

Expand All @@ -154,7 +156,7 @@ def img_aquisition(

while not _event_proc_shutdown.is_set():
now_time = time.time_ns()
if (now_time - last_time) / 1000**3 <= (1 / target_fps):
if (now_time - last_time) / 1000**3 <= (1 / _fps_target):
# limit max framerate to every ~2ms
time.sleep(2 / 1000.0)
continue
Expand Down
Loading

0 comments on commit 3014fe8

Please sign in to comment.