Files
maziggy d4477e9b71 Log ffmpeg's error instead of its build banner
ffmpeg opens every run with ~20 lines of version and build banner and prints
its diagnosis last, so the stderr[:200] eight of the nine call sites used kept
the banner and threw the error away. The reporter's twelve capture failures all
read "ffmpeg version 7.1.4 ... configuration: --prefix=/usr --extra-version=",
identical on every install; the exit code was the only usable byte.

The banner-stripping summariser written for #925 lived private to the camera
route. It now lives in backend/app/utils/ffmpeg_output.py and every ffmpeg and
ffprobe stderr goes through it. Two things the scattered copies also got wrong:
four logged the input URL unmasked, publishing a printer access code or camera
password, and four called a bare .decode() on bytes ffmpeg copies stream
fragments into.

-----

Delete the files a no-3MF archive owns, without taking a printer folder

Both delete paths derived the directory from file_path, which such an archive
does not have, so they removed nothing and logged it at ERROR under a SECURITY
banner. That was true when the archive was an empty row and stopped being true
once one could hold a timelapse and finish photos in <archive_dir>/<id>/ and an
uploaded source in archive/no_source/<id>/.

The two are cleaned up by different means, because <archive_dir>/<id> shares a
namespace with the per-printer folders: a normal archive lives at
<archive_dir>/<printer_id>/<timestamp>_<name>/, so archive/1 is printer 1's
folder and also the directory the shared helper hands archive id 1. Ids come
from unrelated sequences, so the first few archives collide with the printers
on every install, and an rmtree there takes every print that printer made --
measured on a scratch tree. no_source/<id> is a level deeper under a name no
printer id can take and is removed whole; the id-named directory gives up only
its photos subdirectory and the video the row records, then goes only if that
left it empty. The depth guard moves from one to two for the same reason: a
file_path that lost a path component could point the delete at a printer
folder, and no archive directory has been one level deep since the first
commit.

Hard delete had its own copy of these rules, which the helper's docstring says
it exists to prevent, and it had diverged -- it skipped the print-log thumbnail
cleanup whenever a guard tripped.

-----

Stop the RTSPS proxy leaving a handler behind at shutdown

asyncio.start_server keeps only a weak reference to the connection callback's
task, so a handler still awaiting its forwarders could be collected while
pending -- "Task was destroyed but it is pending!", at ERROR with a traceback
into camera.py, once every few hundred snapshots. Teardown had the matching
gap: server.close() leaves established connections running, so the close waited
on a handler that only finishes when the peer drops, and ffmpeg has already
been reaped by then.

Handlers are held for as long as they run and cancelled at shutdown, which is
Server.close_clients() by hand -- that landed in 3.13 and Bambuddy supports
3.10. Both the snapshot path and the streaming endpoint share the shutdown.
2026-08-29 08:51:33 +02:00

271 lines
8.6 KiB
Python

"""Timelapse video processing service using FFmpeg."""
import asyncio
import json
import logging
import tempfile
from pathlib import Path
from backend.app.services.camera import get_ffmpeg_path
from backend.app.utils.ffmpeg_output import NO_FFMPEG_OUTPUT, summarize_ffmpeg_stderr
logger = logging.getLogger(__name__)
class TimelapseProcessor:
"""Service for processing timelapse videos with FFmpeg."""
def __init__(self, input_path: Path):
self.input_path = input_path
self.ffmpeg = get_ffmpeg_path()
if not self.ffmpeg:
raise RuntimeError("FFmpeg not found")
# Derive ffprobe path from ffmpeg path
self.ffprobe = self.ffmpeg.replace("ffmpeg", "ffprobe")
async def get_info(self) -> dict:
"""Get video metadata using ffprobe."""
cmd = [
self.ffprobe,
"-v",
"quiet",
"-print_format",
"json",
"-show_format",
"-show_streams",
str(self.input_path),
]
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
# Summarised once and used for both: the raise carried a second,
# bare ``stderr.decode()`` that could itself raise UnicodeDecodeError
# on the bytes ffprobe copies out of a broken file (#2968).
detail = summarize_ffmpeg_stderr(stderr) or NO_FFMPEG_OUTPUT
logger.error("ffprobe failed: %s", detail)
raise RuntimeError(f"ffprobe failed: {detail}")
data = json.loads(stdout.decode())
video_stream = next(
(s for s in data.get("streams", []) if s.get("codec_type") == "video"),
{},
)
audio_stream = next(
(s for s in data.get("streams", []) if s.get("codec_type") == "audio"),
None,
)
# Parse frame rate (can be "30/1" or "29.97")
fps = 30.0
r_frame_rate = video_stream.get("r_frame_rate", "30/1")
try:
if "/" in r_frame_rate:
num, den = r_frame_rate.split("/")
fps = float(num) / float(den)
else:
fps = float(r_frame_rate)
except (ValueError, ZeroDivisionError):
pass # Keep default fps if frame rate string is unparseable
return {
"duration": float(data.get("format", {}).get("duration", 0)),
"width": video_stream.get("width", 0),
"height": video_stream.get("height", 0),
"fps": fps,
"codec": video_stream.get("codec_name", "unknown"),
"file_size": int(data.get("format", {}).get("size", 0)),
"has_audio": audio_stream is not None,
}
async def generate_thumbnails(
self,
count: int = 10,
width: int = 160,
) -> list[tuple[float, bytes]]:
"""Generate evenly-spaced thumbnail frames."""
info = await self.get_info()
duration = info["duration"]
if duration <= 0:
return []
interval = duration / max(count, 1)
thumbnails = []
with tempfile.TemporaryDirectory() as tmpdir:
for i in range(count):
timestamp = i * interval
output_path = Path(tmpdir) / f"thumb_{i:03d}.jpg"
cmd = [
self.ffmpeg,
"-y",
"-ss",
str(timestamp),
"-i",
str(self.input_path),
"-vframes",
"1",
"-vf",
f"scale={width}:-1",
"-q:v",
"5",
str(output_path),
]
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await process.communicate()
if output_path.exists():
thumbnails.append((timestamp, output_path.read_bytes()))
return thumbnails
async def process(
self,
output_path: Path,
trim_start: float = 0,
trim_end: float | None = None,
speed: float = 1.0,
audio_path: Path | None = None,
audio_volume: float = 1.0,
) -> bool:
"""Process video with trim, speed, and optional audio overlay.
Args:
output_path: Where to save the processed video
trim_start: Start time in seconds
trim_end: End time in seconds (None = full duration)
speed: Speed multiplier (0.25 to 4.0)
audio_path: Optional music file to overlay
audio_volume: Volume for audio overlay (0.0 to 1.0)
Returns:
True if processing succeeded, False otherwise
"""
# Build FFmpeg command
cmd = [self.ffmpeg, "-y"]
# Input seeking (fast seek before input)
if trim_start > 0:
cmd.extend(["-ss", str(trim_start)])
cmd.extend(["-i", str(self.input_path)])
# Add audio input if provided
if audio_path:
cmd.extend(["-i", str(audio_path)])
# Duration limit
if trim_end is not None and trim_end > trim_start:
duration = trim_end - trim_start
cmd.extend(["-t", str(duration)])
# Build filters - use filter_complex when we have audio overlay
video_filter = ""
if speed != 1.0:
# setpts changes video speed: PTS/speed = faster, PTS*speed = slower
setpts_value = 1.0 / speed
video_filter = f"setpts={setpts_value}*PTS"
if audio_path:
# Use filter_complex for audio overlay (can't mix with -vf/-af)
filter_parts = []
# Video filter
if video_filter:
filter_parts.append(f"[0:v]{video_filter}[v]")
video_out = "[v]"
else:
video_out = "0:v"
# Audio filter with volume
filter_parts.append(f"[1:a]volume={audio_volume}[a]")
cmd.extend(["-filter_complex", ";".join(filter_parts)])
cmd.extend(["-map", video_out, "-map", "[a]"])
cmd.extend(["-shortest"])
elif speed != 1.0:
# No audio overlay - use simple -vf and -af
if video_filter:
cmd.extend(["-vf", video_filter])
# Adjust original audio speed with atempo
atempo_chain = self._build_atempo_chain(speed)
if atempo_chain:
cmd.extend(["-af", atempo_chain])
# Output settings
cmd.extend(
[
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"23",
"-c:a",
"aac",
"-b:a",
"128k",
"-movflags",
"+faststart", # Enable streaming
str(output_path),
]
)
logger.info("Processing timelapse: %s", " ".join(cmd))
# Run FFmpeg
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await process.communicate()
if process.returncode != 0:
logger.error("FFmpeg processing failed: %s", summarize_ffmpeg_stderr(stderr) or NO_FFMPEG_OUTPUT)
return False
return output_path.exists()
def _build_atempo_chain(self, speed: float) -> str:
"""Build atempo filter chain.
atempo filter only supports values between 0.5 and 2.0,
so we chain multiple filters for extreme speeds.
"""
if speed == 1.0:
return ""
filters = []
remaining_speed = speed
# Handle speeds > 2.0 by chaining atempo=2.0
while remaining_speed > 2.0:
filters.append("atempo=2.0")
remaining_speed /= 2.0
# Handle speeds < 0.5 by chaining atempo=0.5
while remaining_speed < 0.5:
filters.append("atempo=0.5")
remaining_speed *= 2.0
# Add final atempo for remaining adjustment
# After the while loops above, remaining_speed is guaranteed to be in [0.5, 2.0]
if remaining_speed != 1.0:
filters.append(f"atempo={remaining_speed:.4f}")
return ",".join(filters)