thats-me/backend/app/Services/EventMediaProbe.php
Kevin Adametz b262a6771f Backend: Medien-Typen, Sprachnotizen, Event-Felder und Sync-Härtung
- Event-Medien tragen Typ (image/video/audio), Dauer und Notiz; Videos
  bekommen ein Poster, Audio wird per ffmpeg nach AAC (audio/mp4)
  gewandelt, Originale werden mit Range-Unterstützung gestreamt.
- Upload nimmt eine Notiz (caption) direkt mit, PATCH ändert sie später.
- Neue Event-Felder: Zeitraum (end_date), Kategorie, Personen, Meilenstein.
  Regeln zentral in EventFields, damit CRUD und Sync gleich prüfen.
- Titel darf leer sein: ein Event entsteht mit dem ersten Eintrag.
- Artisan-Befehl media:transcode-voice-notes wandelt alte WebM/Ogg-Notizen,
  die Safari nicht abspielen kann.
- Login-Throttling, Settings-Validierung, Sync-Fehlercodes ohne Rohtext.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 07:00:35 +00:00

134 lines
3.7 KiB
PHP

<?php
namespace App\Services;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Process\ExecutableFinder;
use Symfony\Component\Process\Process;
/**
* Reads the duration of an audio or video file and extracts a poster frame
* from a video, using ffprobe / ffmpeg when they are installed.
*
* Both are optional: without them a video is stored without a poster and a
* recording without a duration. The upload itself never fails because of a
* missing tool.
*/
class EventMediaProbe
{
private const TIMEOUT_SECONDS = 30;
public function durationMs(string $path): ?int
{
$ffprobe = $this->binary('ffprobe');
if ($ffprobe === null) {
return null;
}
$process = new Process([
$ffprobe,
'-v', 'error',
'-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1',
$path,
]);
$process->setTimeout(self::TIMEOUT_SECONDS);
try {
$process->run();
} catch (\Throwable $e) {
Log::info('ffprobe failed', ['path' => $path, 'exception' => $e->getMessage()]);
return null;
}
if (! $process->isSuccessful()) {
return null;
}
$seconds = (float) trim($process->getOutput());
return $seconds > 0 ? (int) round($seconds * 1000) : null;
}
/**
* Write a JPEG poster frame of the video to $targetPath.
*
* @return bool true when the frame was written
*/
public function posterFrame(string $videoPath, string $targetPath, ?int $durationMs = null): bool
{
$ffmpeg = $this->binary('ffmpeg');
if ($ffmpeg === null) {
return false;
}
// A frame a little into the clip avoids the black first frame many
// recordings start with; very short clips take the first frame.
$seek = $durationMs !== null && $durationMs > 2000 ? '0.5' : '0';
$process = new Process([
$ffmpeg,
'-v', 'error',
'-y',
'-ss', $seek,
'-i', $videoPath,
'-frames:v', '1',
'-q:v', '3',
$targetPath,
]);
$process->setTimeout(self::TIMEOUT_SECONDS);
try {
$process->run();
} catch (\Throwable $e) {
Log::info('ffmpeg poster extraction failed', ['path' => $videoPath, 'exception' => $e->getMessage()]);
return false;
}
return $process->isSuccessful() && is_file($targetPath) && filesize($targetPath) > 0;
}
/**
* Re-encode an audio recording as AAC in an MP4 container.
*
* @return bool true when the file was written
*/
public function transcodeAudioToM4a(string $sourcePath, string $targetPath): bool
{
$ffmpeg = $this->binary('ffmpeg');
if ($ffmpeg === null) {
return false;
}
$process = new Process([
$ffmpeg,
'-v', 'error',
'-y',
'-i', $sourcePath,
'-vn',
'-c:a', 'aac',
'-b:a', '96k',
'-movflags', '+faststart',
'-f', 'mp4',
$targetPath,
]);
$process->setTimeout(self::TIMEOUT_SECONDS * 4);
try {
$process->run();
} catch (\Throwable $e) {
Log::info('ffmpeg audio transcode failed', ['path' => $sourcePath, 'exception' => $e->getMessage()]);
return false;
}
return $process->isSuccessful() && is_file($targetPath) && filesize($targetPath) > 0;
}
private function binary(string $name): ?string
{
return (new ExecutableFinder)->find($name);
}
}