- 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>
134 lines
3.7 KiB
PHP
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);
|
|
}
|
|
}
|