- 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>
283 lines
9.6 KiB
PHP
283 lines
9.6 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Event;
|
|
use App\Models\EventMedia;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
|
|
/**
|
|
* Stores a file as event media and creates the row.
|
|
*
|
|
* Images get an original, a preview and a thumbnail. Videos keep their
|
|
* original file and get a poster frame (preview + thumbnail) when ffmpeg is
|
|
* available. Voice notes are transcoded to AAC in an MP4 container when
|
|
* ffmpeg is available, because browser recordings (WebM/Opus) lack duration
|
|
* and index data and do not play in Safari at all. An event can carry any
|
|
* number of voice notes. The key image points the event at the new
|
|
* thumbnail. Shared by the API upload and the lifeline importer.
|
|
*/
|
|
class EventMediaStore
|
|
{
|
|
public const TYPE_IMAGE = 'image';
|
|
|
|
public const TYPE_VIDEO = 'video';
|
|
|
|
public const TYPE_AUDIO = 'audio';
|
|
|
|
public function __construct(
|
|
private readonly EventMediaImageProcessor $processor,
|
|
private readonly EventMediaProbe $probe,
|
|
private readonly MediaCleaner $mediaCleaner,
|
|
) {}
|
|
|
|
public function store(Event $event, UploadedFile $file, string $collection = 'gallery', ?string $caption = null): EventMedia
|
|
{
|
|
$type = $this->typeFor($file, $collection);
|
|
$uuid = (string) Str::uuid();
|
|
$directory = "event-media/{$event->user_id}/{$event->client_id}";
|
|
|
|
$attributes = match ($type) {
|
|
self::TYPE_IMAGE => $this->storeImage($file, $directory, $uuid),
|
|
self::TYPE_VIDEO => $this->storeVideo($file, $directory, $uuid),
|
|
default => $this->storeAudio($file, $directory, $uuid),
|
|
};
|
|
|
|
if ($collection === 'key_image') {
|
|
$this->deleteExistingInCollection($event, 'key_image');
|
|
}
|
|
|
|
$media = $event->media()->create([
|
|
'uuid' => $uuid,
|
|
'user_id' => $event->user_id,
|
|
'collection' => $collection,
|
|
'type' => $type,
|
|
'caption' => $caption !== null && $caption !== '' ? $caption : null,
|
|
'name' => $file->getClientOriginalName(),
|
|
'disk' => 'local',
|
|
...$attributes,
|
|
]);
|
|
|
|
if ($collection === 'key_image') {
|
|
$event->update(['image' => "/event-media/{$media->id}/thumb"]);
|
|
}
|
|
|
|
return $media;
|
|
}
|
|
|
|
public function typeFor(UploadedFile $file, string $collection): string
|
|
{
|
|
if ($collection === 'voice_note') {
|
|
return self::TYPE_AUDIO;
|
|
}
|
|
|
|
$mime = (string) ($file->getMimeType() ?: $file->getClientMimeType());
|
|
|
|
return str_starts_with($mime, 'video/') ? self::TYPE_VIDEO : self::TYPE_IMAGE;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function storeImage(UploadedFile $file, string $directory, string $uuid): array
|
|
{
|
|
$processed = $this->processor->process($file);
|
|
$path = "{$directory}/{$uuid}.jpg";
|
|
$previewPath = "{$directory}/{$uuid}_preview.jpg";
|
|
$thumbnailPath = "{$directory}/{$uuid}_thumb.jpg";
|
|
|
|
Storage::disk('local')->put($path, $processed['original']);
|
|
Storage::disk('local')->put($previewPath, $processed['preview']);
|
|
Storage::disk('local')->put($thumbnailPath, $processed['thumbnail']);
|
|
|
|
return [
|
|
'mime_type' => $processed['mime_type'],
|
|
'path' => $path,
|
|
'thumbnail_path' => $thumbnailPath,
|
|
'preview_path' => $previewPath,
|
|
'size' => $processed['size'],
|
|
'duration_ms' => null,
|
|
'width' => $processed['width'],
|
|
'height' => $processed['height'],
|
|
'thumbnail_width' => $processed['thumbnail_width'],
|
|
'thumbnail_height' => $processed['thumbnail_height'],
|
|
'preview_width' => $processed['preview_width'],
|
|
'preview_height' => $processed['preview_height'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function storeVideo(UploadedFile $file, string $directory, string $uuid): array
|
|
{
|
|
$extension = $this->extensionFor($file, 'mp4');
|
|
$path = "{$directory}/{$uuid}.{$extension}";
|
|
$realPath = (string) $file->getRealPath();
|
|
|
|
Storage::disk('local')->putFileAs($directory, $file, "{$uuid}.{$extension}");
|
|
|
|
$durationMs = $this->probe->durationMs($realPath);
|
|
$poster = $this->posterFor($realPath, $durationMs);
|
|
|
|
$attributes = [
|
|
'mime_type' => $file->getMimeType() ?: ($file->getClientMimeType() ?: 'video/mp4'),
|
|
'path' => $path,
|
|
'thumbnail_path' => null,
|
|
'preview_path' => null,
|
|
'size' => $file->getSize(),
|
|
'duration_ms' => $durationMs,
|
|
'width' => null,
|
|
'height' => null,
|
|
'thumbnail_width' => null,
|
|
'thumbnail_height' => null,
|
|
'preview_width' => null,
|
|
'preview_height' => null,
|
|
];
|
|
|
|
if ($poster !== null) {
|
|
$previewPath = "{$directory}/{$uuid}_preview.jpg";
|
|
$thumbnailPath = "{$directory}/{$uuid}_thumb.jpg";
|
|
|
|
Storage::disk('local')->put($previewPath, $poster['preview']);
|
|
Storage::disk('local')->put($thumbnailPath, $poster['thumbnail']);
|
|
|
|
$attributes = [
|
|
...$attributes,
|
|
'thumbnail_path' => $thumbnailPath,
|
|
'preview_path' => $previewPath,
|
|
'width' => $poster['width'],
|
|
'height' => $poster['height'],
|
|
'thumbnail_width' => $poster['thumbnail_width'],
|
|
'thumbnail_height' => $poster['thumbnail_height'],
|
|
'preview_width' => $poster['preview_width'],
|
|
'preview_height' => $poster['preview_height'],
|
|
];
|
|
}
|
|
|
|
return $attributes;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function storeAudio(UploadedFile $file, string $directory, string $uuid): array
|
|
{
|
|
$transcoded = $this->transcodedAudio($file);
|
|
|
|
if ($transcoded !== null) {
|
|
$path = "{$directory}/{$uuid}.m4a";
|
|
Storage::disk('local')->put($path, (string) file_get_contents($transcoded));
|
|
$durationMs = $this->probe->durationMs($transcoded);
|
|
$size = (int) filesize($transcoded);
|
|
@unlink($transcoded);
|
|
|
|
return $this->audioAttributes($path, 'audio/mp4', $size, $durationMs);
|
|
}
|
|
|
|
// Without ffmpeg the recording is kept as delivered.
|
|
$extension = $this->extensionFor($file, 'webm');
|
|
$path = "{$directory}/{$uuid}.{$extension}";
|
|
|
|
Storage::disk('local')->putFileAs($directory, $file, "{$uuid}.{$extension}");
|
|
|
|
// Browsers record audio into containers libmagic reports as video
|
|
// (WebM, MP4). The client's own type is the honest one here.
|
|
$clientMime = (string) $file->getClientMimeType();
|
|
$mime = str_starts_with($clientMime, 'audio/')
|
|
? $clientMime
|
|
: ($file->getMimeType() ?: 'audio/webm');
|
|
|
|
return $this->audioAttributes(
|
|
$path,
|
|
$mime,
|
|
(int) $file->getSize(),
|
|
$this->probe->durationMs((string) $file->getRealPath()),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Path of a temporary AAC/MP4 rendition of the upload, or null when
|
|
* ffmpeg is unavailable or the input could not be decoded.
|
|
*/
|
|
private function transcodedAudio(UploadedFile $file): ?string
|
|
{
|
|
$target = tempnam(sys_get_temp_dir(), 'voice_').'.m4a';
|
|
|
|
if ($this->probe->transcodeAudioToM4a((string) $file->getRealPath(), $target)) {
|
|
@unlink(substr($target, 0, -4));
|
|
|
|
return $target;
|
|
}
|
|
|
|
@unlink($target);
|
|
@unlink(substr($target, 0, -4));
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function audioAttributes(string $path, string $mime, int $size, ?int $durationMs): array
|
|
{
|
|
return [
|
|
'mime_type' => $mime,
|
|
'path' => $path,
|
|
'thumbnail_path' => null,
|
|
'preview_path' => null,
|
|
'size' => $size,
|
|
'duration_ms' => $durationMs,
|
|
'width' => null,
|
|
'height' => null,
|
|
'thumbnail_width' => null,
|
|
'thumbnail_height' => null,
|
|
'preview_width' => null,
|
|
'preview_height' => null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Poster frame of a video, run through the image pipeline so it gets the
|
|
* same preview and thumbnail sizes as an uploaded photo.
|
|
*
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
private function posterFor(string $videoPath, ?int $durationMs): ?array
|
|
{
|
|
$posterPath = tempnam(sys_get_temp_dir(), 'poster_').'.jpg';
|
|
|
|
try {
|
|
if (! $this->probe->posterFrame($videoPath, $posterPath, $durationMs)) {
|
|
return null;
|
|
}
|
|
|
|
$poster = new UploadedFile($posterPath, 'poster.jpg', 'image/jpeg', null, true);
|
|
|
|
return $this->processor->process($poster);
|
|
} catch (\Throwable) {
|
|
return null;
|
|
} finally {
|
|
@unlink($posterPath);
|
|
@unlink(substr($posterPath, 0, -4));
|
|
}
|
|
}
|
|
|
|
private function extensionFor(UploadedFile $file, string $fallback): string
|
|
{
|
|
$extension = strtolower((string) ($file->getClientOriginalExtension() ?: $file->guessExtension()));
|
|
$extension = preg_replace('/[^a-z0-9]/', '', $extension) ?? '';
|
|
|
|
return $extension !== '' ? $extension : $fallback;
|
|
}
|
|
|
|
private function deleteExistingInCollection(Event $event, string $collection): void
|
|
{
|
|
foreach ($event->media()->where('collection', $collection)->get() as $media) {
|
|
$this->mediaCleaner->purgeMedia($media);
|
|
$media->delete();
|
|
}
|
|
}
|
|
}
|