event photo wasserzeichen umgesetzt. Event admins können eigene einsetzen (als branding) falls das Paket es erlaubt. der Super Admin kann für die günstigen Pakete eigene Wasserzeichen erzwingen

This commit is contained in:
Codex Agent
2025-11-22 14:25:48 +01:00
parent eb41cb6194
commit 3d9eaa1194
25 changed files with 1124 additions and 71 deletions

View File

@@ -258,6 +258,7 @@ class EventResource extends Resource
'create' => Pages\CreateEvent::route('/create'),
'view' => Pages\ViewEvent::route('/{record}'),
'edit' => Pages\EditEvent::route('/{record}/edit'),
'watermark' => Pages\ManageWatermark::route('/{record}/watermark'),
];
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Filament\Resources\EventResource\Pages;
use App\Filament\Resources\EventResource;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\Page;
use Illuminate\Support\Arr;
class ManageWatermark extends Page
{
protected static string $resource = EventResource::class;
protected string $view = 'filament.resources.event-resource.pages.manage-watermark';
public ?string $watermark_mode = 'base';
public ?string $watermark_asset = null;
public string $watermark_position = 'bottom-right';
public float $watermark_opacity = 0.25;
public float $watermark_scale = 0.2;
public int $watermark_padding = 16;
public bool $serve_originals = false;
public function mount(): void
{
$event = $this->record;
$settings = $event->settings ?? [];
$watermark = Arr::get($settings, 'watermark', []);
$this->watermark_mode = $watermark['mode'] ?? 'base';
$this->watermark_asset = $watermark['asset'] ?? null;
$this->watermark_position = $watermark['position'] ?? 'bottom-right';
$this->watermark_opacity = (float) ($watermark['opacity'] ?? 0.25);
$this->watermark_scale = (float) ($watermark['scale'] ?? 0.2);
$this->watermark_padding = (int) ($watermark['padding'] ?? 16);
$this->serve_originals = (bool) Arr::get($settings, 'watermark_serve_originals', false);
}
protected function getForms(): array
{
return [
'form' => $this->form(
$this->makeForm()
->schema([
Forms\Components\Fieldset::make(__('filament-watermark.heading'))
->schema([
Forms\Components\Select::make('watermark_mode')
->label(__('filament-watermark.mode.label'))
->options([
'base' => __('filament-watermark.mode.base'),
'custom' => __('filament-watermark.mode.custom'),
'off' => __('filament-watermark.mode.off'),
])
->required(),
Forms\Components\FileUpload::make('watermark_asset')
->label(__('filament-watermark.asset'))
->disk('public')
->directory('branding')
->preserveFilenames()
->image()
->visible(fn (callable $get) => $get('watermark_mode') === 'custom'),
Forms\Components\Select::make('watermark_position')
->label(__('filament-watermark.position'))
->options([
'top-left' => 'Top Left',
'top-right' => 'Top Right',
'bottom-left' => 'Bottom Left',
'bottom-right' => 'Bottom Right',
'center' => 'Center',
])
->required(),
Forms\Components\TextInput::make('watermark_opacity')
->label(__('filament-watermark.opacity'))
->numeric()
->minValue(0)
->maxValue(1)
->step(0.05)
->required(),
Forms\Components\TextInput::make('watermark_scale')
->label(__('filament-watermark.scale'))
->numeric()
->minValue(0.05)
->maxValue(1)
->step(0.05)
->required(),
Forms\Components\TextInput::make('watermark_padding')
->label(__('filament-watermark.padding'))
->numeric()
->minValue(0)
->required(),
Forms\Components\Toggle::make('serve_originals')
->label(__('filament-watermark.serve_originals'))
->helperText('Nur Admin/Owner: falls aktiviert, werden Originale statt watermarked ausgeliefert.')
->default(false),
])
->columns(2),
])
),
];
}
public function save(): void
{
$event = $this->record;
$package = $event->eventPackage?->package;
$brandingAllowed = $package?->branding_allowed === true;
$policy = $package?->watermark_allowed === false ? 'none' : 'basic';
if (! $brandingAllowed && $this->watermark_mode === 'custom') {
Notification::make()->title('Branding nicht erlaubt für dieses Paket')->danger()->send();
return;
}
if ($policy === 'basic' && $this->watermark_mode === 'off') {
Notification::make()->title('Kein Wasserzeichen ist in diesem Paket nicht erlaubt')->danger()->send();
return;
}
$settings = $event->settings ?? [];
$settings['watermark'] = [
'mode' => $this->watermark_mode,
'asset' => $this->watermark_asset,
'position' => $this->watermark_position,
'opacity' => (float) $this->watermark_opacity,
'scale' => (float) $this->watermark_scale,
'padding' => (int) $this->watermark_padding,
];
$settings['watermark_serve_originals'] = $this->serve_originals;
$event->forceFill(['settings' => $settings])->save();
Notification::make()
->title(__('filament-watermark.saved'))
->success()
->send();
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Filament\SuperAdmin\Pages;
use App\Models\WatermarkSetting;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Pages\Page;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Storage;
class WatermarkSettingsPage extends Page
{
protected static null|string|\BackedEnum $navigationIcon = 'heroicon-o-sparkles';
protected string $view = 'filament.super-admin.pages.watermark-settings-page';
protected static null|string|\UnitEnum $navigationGroup = 'Branding';
protected static ?int $navigationSort = 20;
public ?string $asset = null;
public string $position = 'bottom-right';
public float $opacity = 0.25;
public float $scale = 0.2;
public int $padding = 16;
public function mount(): void
{
$settings = WatermarkSetting::query()->first();
if ($settings) {
$this->asset = $settings->asset;
$this->position = $settings->position;
$this->opacity = (float) $settings->opacity;
$this->scale = (float) $settings->scale;
$this->padding = (int) $settings->padding;
}
}
public function form(Form $form): Form
{
return $form->schema([
Forms\Components\FileUpload::make('asset')
->label('Basis-Wasserzeichen (PNG/SVG empfohlen)')
->disk('public')
->directory('branding')
->preserveFilenames()
->image()
->required(),
Forms\Components\Select::make('position')
->label('Position')
->options([
'top-left' => 'Oben links',
'top-right' => 'Oben rechts',
'bottom-left' => 'Unten links',
'bottom-right' => 'Unten rechts',
'center' => 'Zentriert',
])
->required(),
Forms\Components\TextInput::make('opacity')
->label('Opacity (01)')
->numeric()
->minValue(0)
->maxValue(1)
->step(0.05)
->default(0.25)
->required(),
Forms\Components\TextInput::make('scale')
->label('Skalierung (01, relativ zur Bildbreite)')
->numeric()
->minValue(0.05)
->maxValue(1)
->step(0.05)
->default(0.2)
->required(),
Forms\Components\TextInput::make('padding')
->label('Padding (px)')
->numeric()
->minValue(0)
->default(16)
->required(),
])->columns(2);
}
public function save(): void
{
$this->validate();
$settings = WatermarkSetting::query()->firstOrNew([]);
$settings->asset = $this->asset;
$settings->position = $this->position;
$settings->opacity = $this->opacity;
$settings->scale = $this->scale;
$settings->padding = $this->padding;
$settings->save();
Notification::make()
->title('Wasserzeichen aktualisiert')
->success()
->send();
}
}

View File

@@ -23,6 +23,7 @@ use App\Services\PushSubscriptionService;
use App\Services\Storage\EventStorageManager;
use App\Support\ApiError;
use App\Support\ImageHelper;
use App\Support\WatermarkConfigResolver;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
@@ -877,14 +878,32 @@ class EventPublicController extends BaseController
return $fallback;
}
private function determineBrandingAllowed(Event $event): bool
{
return WatermarkConfigResolver::determineBrandingAllowed($event);
}
private function determineWatermarkPolicy(Event $event): string
{
return WatermarkConfigResolver::determinePolicy($event);
}
private function resolveWatermarkConfig(Event $event): array
{
return WatermarkConfigResolver::resolve($event);
}
private function buildGalleryBranding(Event $event): array
{
$defaultPrimary = '#f43f5e';
$defaultSecondary = '#fb7185';
$defaultBackground = '#ffffff';
$eventBranding = Arr::get($event->settings, 'branding', []);
$tenantBranding = Arr::get($event->tenant?->settings, 'branding', []);
$event->loadMissing('eventPackage.package', 'tenant');
$brandingAllowed = $this->determineBrandingAllowed($event);
$eventBranding = $brandingAllowed ? Arr::get($event->settings, 'branding', []) : [];
$tenantBranding = $brandingAllowed ? Arr::get($event->tenant?->settings, 'branding', []) : [];
return [
'primary_color' => Arr::get($eventBranding, 'primary_color')
@@ -1239,6 +1258,7 @@ class EventPublicController extends BaseController
$variantPreference = $variant === 'thumbnail'
? ['thumbnail', 'original']
: ['original'];
$preferOriginals = (bool) ($event->settings['watermark_serve_originals'] ?? false);
return $this->streamGalleryPhoto($event, $photo, $variantPreference, 'inline');
}
@@ -1360,8 +1380,11 @@ class EventPublicController extends BaseController
$branding = $this->buildGalleryBranding($event);
$fontFamily = Arr::get($event->settings, 'branding.font_family')
?? Arr::get($event->tenant?->settings, 'branding.font_family');
$logoUrl = Arr::get($event->settings, 'branding.logo_url')
?? Arr::get($event->tenant?->settings, 'branding.logo_url');
$brandingAllowed = $this->determineBrandingAllowed($event);
$logoUrl = $brandingAllowed
? (Arr::get($event->settings, 'branding.logo_url')
?? Arr::get($event->tenant?->settings, 'branding.logo_url'))
: null;
if ($joinToken) {
$this->joinTokenService->incrementUsage($joinToken);
@@ -1430,6 +1453,9 @@ class EventPublicController extends BaseController
$package = $eventPackage->package;
$summary = $this->packageLimitEvaluator->summarizeEventPackage($eventPackage);
$watermarkPolicy = $this->determineWatermarkPolicy($event);
$brandingAllowed = $this->determineBrandingAllowed($event);
$watermark = $this->resolveWatermarkConfig($event);
return response()->json([
'id' => $eventPackage->id,
@@ -1441,18 +1467,23 @@ class EventPublicController extends BaseController
'max_photos' => $package?->max_photos,
'max_guests' => $package?->max_guests,
'gallery_days' => $package?->gallery_days,
'watermark_policy' => $watermarkPolicy,
'branding_allowed' => $brandingAllowed,
],
'used_photos' => (int) $eventPackage->used_photos,
'used_guests' => (int) $eventPackage->used_guests,
'expires_at' => $eventPackage->gallery_expires_at?->toIso8601String(),
'limits' => $summary,
'watermark' => $watermark,
])->header('Cache-Control', 'no-store');
}
private function streamGalleryPhoto(Event $event, Photo $record, array $variantPreference, string $disposition)
{
$preferOriginals = (bool) ($event->settings['watermark_serve_originals'] ?? false);
foreach ($variantPreference as $variant) {
[$disk, $path, $mime] = $this->resolvePhotoVariant($record, $variant);
[$disk, $path, $mime] = $this->resolvePhotoVariant($record, $variant, $preferOriginals);
if (! $path) {
continue;
@@ -1550,18 +1581,24 @@ class EventPublicController extends BaseController
);
}
private function resolvePhotoVariant(Photo $record, string $variant): array
private function resolvePhotoVariant(Photo $record, string $variant, bool $preferOriginals = false): array
{
if ($variant === 'thumbnail') {
$asset = EventMediaAsset::where('photo_id', $record->id)->where('variant', 'thumbnail')->first();
$watermarked = $preferOriginals
? null
: EventMediaAsset::where('photo_id', $record->id)->where('variant', 'watermarked_thumbnail')->first();
$disk = $asset?->disk ?? $record->mediaAsset?->disk;
$path = $asset?->path ?? ($record->thumbnail_path ?: $record->file_path);
$mime = $asset?->mime_type ?? 'image/jpeg';
$path = $watermarked?->path ?? $asset?->path ?? ($record->thumbnail_path ?: $record->file_path);
$mime = $watermarked?->mime_type ?? $asset?->mime_type ?? 'image/jpeg';
} else {
$asset = $record->mediaAsset ?? EventMediaAsset::where('photo_id', $record->id)->where('variant', 'original')->first();
$watermarked = $preferOriginals
? null
: EventMediaAsset::where('photo_id', $record->id)->where('variant', 'watermarked')->first();
$asset = $record->mediaAsset ?? $watermarked ?? EventMediaAsset::where('photo_id', $record->id)->where('variant', 'original')->first();
$disk = $asset?->disk ?? $record->mediaAsset?->disk;
$path = $asset?->path ?? ($record->file_path ?? null);
$mime = $asset?->mime_type ?? ($record->mime_type ?? 'image/jpeg');
$path = $watermarked?->path ?? $asset?->path ?? ($record->file_path ?? null);
$mime = $watermarked?->mime_type ?? $asset?->mime_type ?? ($record->mime_type ?? 'image/jpeg');
}
return [
@@ -2363,7 +2400,7 @@ class EventPublicController extends BaseController
$file = $validated['photo'];
$disk = $this->eventStorageManager->getHotDiskForEvent($eventModel);
$path = Storage::disk($disk)->putFile("events/{$eventId}/photos", $file);
$url = $this->resolveDiskUrl($disk, $path);
$watermarkConfig = WatermarkConfigResolver::resolve($eventModel);
// Generate thumbnail (JPEG) under photos/thumbs
$baseName = pathinfo($path, PATHINFO_FILENAME);
@@ -2371,7 +2408,27 @@ class EventPublicController extends BaseController
$thumbPath = ImageHelper::makeThumbnailOnDisk($disk, $path, $thumbRel, 640, 82);
$thumbUrl = $thumbPath
? $this->resolveDiskUrl($disk, $thumbPath)
: $url;
: $this->resolveDiskUrl($disk, $path);
// Create watermarked copies (non-destructive).
$watermarkedPath = $path;
$watermarkedThumb = $thumbPath ?: $path;
if ($watermarkConfig['type'] !== 'none' && ! empty($watermarkConfig['asset']) && ! ($watermarkConfig['serve_originals'] ?? false)) {
$watermarkedPath = ImageHelper::copyWithWatermark($disk, $path, "events/{$eventId}/photos/watermarked/{$baseName}.{$file->getClientOriginalExtension()}", $watermarkConfig) ?? $path;
if ($thumbPath) {
$watermarkedThumb = ImageHelper::copyWithWatermark(
$disk,
$thumbPath,
"events/{$eventId}/photos/watermarked/{$baseName}_thumb.jpg",
$watermarkConfig
) ?? $thumbPath;
} else {
$watermarkedThumb = $watermarkedPath;
}
}
$url = $this->resolveDiskUrl($disk, $watermarkedPath);
$thumbUrl = $this->resolveDiskUrl($disk, $watermarkedThumb);
$photoId = DB::table('photos')->insertGetId([
'event_id' => $eventId,
@@ -2390,16 +2447,35 @@ class EventPublicController extends BaseController
'updated_at' => now(),
]);
$storedPath = Storage::disk($disk)->path($path);
$storedSize = Storage::disk($disk)->exists($path) ? Storage::disk($disk)->size($path) : $file->getSize();
$asset = $this->eventStorageManager->recordAsset($eventModel, $disk, $path, [
'variant' => 'original',
'mime_type' => $file->getClientMimeType(),
'size_bytes' => $file->getSize(),
'checksum' => hash_file('sha256', $file->getRealPath()),
'size_bytes' => $storedSize,
'checksum' => file_exists($storedPath) ? hash_file('sha256', $storedPath) : hash_file('sha256', $file->getRealPath()),
'status' => 'hot',
'processed_at' => now(),
'photo_id' => $photoId,
]);
$watermarkedAsset = null;
if ($watermarkedPath !== $path) {
$watermarkedAsset = $this->eventStorageManager->recordAsset($eventModel, $disk, $watermarkedPath, [
'variant' => 'watermarked',
'mime_type' => $file->getClientMimeType(),
'size_bytes' => Storage::disk($disk)->exists($watermarkedPath)
? Storage::disk($disk)->size($watermarkedPath)
: null,
'status' => 'hot',
'processed_at' => now(),
'photo_id' => $photoId,
'meta' => [
'source_variant_id' => $asset->id,
],
]);
}
if ($thumbPath) {
$this->eventStorageManager->recordAsset($eventModel, $disk, $thumbPath, [
'variant' => 'thumbnail',
@@ -2415,6 +2491,21 @@ class EventPublicController extends BaseController
],
]);
}
if ($watermarkedThumb !== $thumbPath) {
$this->eventStorageManager->recordAsset($eventModel, $disk, $watermarkedThumb, [
'variant' => 'watermarked_thumbnail',
'mime_type' => 'image/jpeg',
'status' => 'hot',
'processed_at' => now(),
'photo_id' => $photoId,
'size_bytes' => Storage::disk($disk)->exists($watermarkedThumb)
? Storage::disk($disk)->size($watermarkedThumb)
: null,
'meta' => [
'source_variant_id' => $watermarkedAsset?->id ?? $asset->id,
],
]);
}
DB::table('photos')
->where('id', $photoId)

View File

@@ -14,6 +14,7 @@ use App\Services\Packages\PackageUsageTracker;
use App\Services\Storage\EventStorageManager;
use App\Support\ApiError;
use App\Support\ImageHelper;
use App\Support\WatermarkConfigResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -153,13 +154,38 @@ class PhotoController extends Controller
$thumbnailPath = $thumbnailRelative;
}
// Apply watermark policy (in-place) to original and thumbnail where applicable.
$watermarkConfig = WatermarkConfigResolver::resolve($event);
if ($watermarkConfig['type'] !== 'none' && ! empty($watermarkConfig['asset'])) {
ImageHelper::applyWatermarkOnDisk($disk, $path, $watermarkConfig);
if ($thumbnailRelative) {
ImageHelper::applyWatermarkOnDisk($disk, $thumbnailPath, $watermarkConfig);
}
}
$watermarkConfig = WatermarkConfigResolver::resolve($event);
$watermarkedPath = $path;
$watermarkedThumb = $thumbnailPath;
if ($watermarkConfig['type'] !== 'none' && ! empty($watermarkConfig['asset']) && ! ($watermarkConfig['serve_originals'] ?? false)) {
$watermarkedPath = ImageHelper::copyWithWatermark($disk, $path, "events/{$eventSlug}/watermarked/{$filename}", $watermarkConfig) ?? $path;
if ($thumbnailRelative) {
$watermarkedThumb = ImageHelper::copyWithWatermark(
$disk,
$thumbnailPath,
"events/{$eventSlug}/watermarked/thumbnails/{$filename}",
$watermarkConfig
) ?? $thumbnailPath;
}
}
$photoAttributes = [
'event_id' => $event->id,
'original_name' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
'file_path' => $path,
'thumbnail_path' => $thumbnailPath,
'file_path' => $watermarkedPath,
'thumbnail_path' => $watermarkedThumb,
'width' => null, // Filled below
'height' => null,
'status' => 'pending', // Requires moderation
@@ -179,17 +205,36 @@ class PhotoController extends Controller
$photo = Photo::create($photoAttributes);
// Record primary asset metadata
$checksum = hash_file('sha256', $file->getRealPath());
$storedPath = Storage::disk($disk)->path($path);
$checksum = file_exists($storedPath) ? hash_file('sha256', $storedPath) : hash_file('sha256', $file->getRealPath());
$storedSize = Storage::disk($disk)->exists($path) ? Storage::disk($disk)->size($path) : $file->getSize();
$asset = $this->eventStorageManager->recordAsset($event, $disk, $path, [
'variant' => 'original',
'mime_type' => $file->getMimeType(),
'size_bytes' => $file->getSize(),
'size_bytes' => $storedSize,
'checksum' => $checksum,
'status' => 'hot',
'processed_at' => now(),
'photo_id' => $photo->id,
]);
$watermarkedAsset = null;
if ($watermarkedPath !== $path) {
$watermarkedAsset = $this->eventStorageManager->recordAsset($event, $disk, $watermarkedPath, [
'variant' => 'watermarked',
'mime_type' => $file->getMimeType(),
'size_bytes' => Storage::disk($disk)->exists($watermarkedPath)
? Storage::disk($disk)->size($watermarkedPath)
: null,
'status' => 'hot',
'processed_at' => now(),
'photo_id' => $photo->id,
'meta' => [
'source_variant_id' => $asset->id,
],
]);
}
if ($thumbnailRelative) {
$this->eventStorageManager->recordAsset($event, $disk, $thumbnailRelative, [
'variant' => 'thumbnail',
@@ -205,6 +250,21 @@ class PhotoController extends Controller
],
]);
}
if ($watermarkedThumb !== $thumbnailPath) {
$this->eventStorageManager->recordAsset($event, $disk, $watermarkedThumb, [
'variant' => 'watermarked_thumbnail',
'mime_type' => 'image/jpeg',
'status' => 'hot',
'processed_at' => now(),
'photo_id' => $photo->id,
'size_bytes' => Storage::disk($disk)->exists($watermarkedThumb)
? Storage::disk($disk)->size($watermarkedThumb)
: null,
'meta' => [
'source_variant_id' => $watermarkedAsset?->id ?? $asset->id,
],
]);
}
$photo->update(['media_asset_id' => $asset->id]);

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class WatermarkSetting extends Model
{
use HasFactory;
protected $fillable = [
'asset',
'position',
'opacity',
'scale',
'padding',
];
}

View File

@@ -89,6 +89,10 @@ class SuperAdminPanelProvider extends PanelProvider
LegalPageResource::class,
InfrastructureActionLogResource::class,
])
->pages([
Pages\Dashboard::class,
\App\Filament\SuperAdmin\Pages\WatermarkSettingsPage::class,
])
->authGuard('web');
// SuperAdmin-Zugriff durch custom Middleware, globale Sichtbarkeit ohne Tenant-Isolation

View File

@@ -140,6 +140,29 @@ class PhotoboothIngestService
$thumbnailRelative = ImageHelper::makeThumbnailOnDisk($destinationDisk, $destinationPath, $thumbnailPath, 640, 82);
$thumbnailToStore = $thumbnailRelative ?? $destinationPath;
// Create watermarked copies (non-destructive).
$watermarkConfig = \App\Support\WatermarkConfigResolver::resolve($event);
$watermarkedPath = $destinationPath;
$watermarkedThumb = $thumbnailToStore;
if ($watermarkConfig['type'] !== 'none' && ! empty($watermarkConfig['asset']) && ! ($watermarkConfig['serve_originals'] ?? false)) {
$watermarkedPath = ImageHelper::copyWithWatermark(
$destinationDisk,
$destinationPath,
"events/{$eventSlug}/watermarked/{$filename}",
$watermarkConfig
) ?? $destinationPath;
if ($thumbnailRelative) {
$watermarkedThumb = ImageHelper::copyWithWatermark(
$destinationDisk,
$thumbnailToStore,
"events/{$eventSlug}/watermarked/thumbnails/{$filename}",
$watermarkConfig
) ?? $thumbnailToStore;
} else {
$watermarkedThumb = $watermarkedPath;
}
}
$size = Storage::disk($destinationDisk)->size($destinationPath);
$mimeType = Storage::disk($destinationDisk)->mimeType($destinationPath) ?? 'image/jpeg';
$originalName = basename($file);
@@ -165,8 +188,8 @@ class PhotoboothIngestService
'original_name' => $originalName,
'mime_type' => $mimeType,
'size' => $size,
'file_path' => $destinationPath,
'thumbnail_path' => $thumbnailToStore,
'file_path' => $watermarkedPath,
'thumbnail_path' => $watermarkedThumb,
'status' => 'pending',
'guest_name' => Photo::SOURCE_PHOTOBOOTH,
'ingest_source' => Photo::SOURCE_PHOTOBOOTH,
@@ -192,6 +215,23 @@ class PhotoboothIngestService
'photo_id' => $photo->id,
]);
$watermarkedAsset = null;
if ($watermarkedPath !== $destinationPath) {
$watermarkedAsset = $this->storageManager->recordAsset($event, $destinationDisk, $watermarkedPath, [
'variant' => 'watermarked',
'mime_type' => $mimeType,
'size_bytes' => Storage::disk($destinationDisk)->exists($watermarkedPath)
? Storage::disk($destinationDisk)->size($watermarkedPath)
: null,
'status' => 'hot',
'processed_at' => now(),
'photo_id' => $photo->id,
'meta' => [
'source_variant_id' => $asset->id,
],
]);
}
if ($thumbnailRelative) {
$this->storageManager->recordAsset($event, $destinationDisk, $thumbnailRelative, [
'variant' => 'thumbnail',
@@ -201,6 +241,21 @@ class PhotoboothIngestService
'photo_id' => $photo->id,
]);
}
if ($watermarkedThumb !== $thumbnailToStore) {
$this->storageManager->recordAsset($event, $destinationDisk, $watermarkedThumb, [
'variant' => 'watermarked_thumbnail',
'mime_type' => 'image/jpeg',
'status' => 'hot',
'processed_at' => now(),
'photo_id' => $photo->id,
'size_bytes' => Storage::disk($destinationDisk)->exists($watermarkedThumb)
? Storage::disk($destinationDisk)->size($watermarkedThumb)
: null,
'meta' => [
'source_variant_id' => $watermarkedAsset?->id ?? $asset->id,
],
]);
}
$photo->update(['media_asset_id' => $asset->id]);

View File

@@ -13,6 +13,7 @@ trait PresentsPackages
$packageArray = $package->toArray();
$features = $packageArray['features'] ?? [];
$features = $this->normaliseFeatures($features);
$watermarkPolicy = $package->watermark_allowed === false ? 'none' : 'basic';
$locale = app()->getLocale();
$name = $this->resolveTranslation($package->name_translations ?? null, $package->name ?? '', $locale);
@@ -50,6 +51,7 @@ trait PresentsPackages
'gallery_duration_label' => $galleryDuration,
'events' => $package->type === 'endcustomer' ? 1 : ($package->max_events_per_year ?? null),
'features' => $features,
'watermark_policy' => $watermarkPolicy,
'limits' => $package->limits,
'max_photos' => $package->max_photos,
'max_guests' => $package->max_guests,

View File

@@ -65,5 +65,122 @@ class ImageHelper
return null;
}
}
}
/**
* Apply a watermark in-place on the given disk/path.
* Expects $config with keys: asset (path), position, opacity (0..1), scale (0..1), padding (px).
*/
public static function applyWatermarkOnDisk(string $disk, string $path, array $config): bool
{
$fullSrc = Storage::disk($disk)->path($path);
if (! file_exists($fullSrc)) {
return false;
}
$assetPath = $config['asset'] ?? null;
if (! $assetPath) {
return false;
}
$assetFull = null;
if (Storage::disk('public')->exists($assetPath)) {
$assetFull = Storage::disk('public')->path($assetPath);
} elseif (file_exists($assetPath)) {
$assetFull = $assetPath;
}
if (! $assetFull || ! file_exists($assetFull)) {
return false;
}
try {
$data = @file_get_contents($fullSrc);
$src = $data !== false ? @imagecreatefromstring($data) : null;
if (! $src) {
return false;
}
$wmData = @file_get_contents($assetFull);
$watermark = $wmData !== false ? @imagecreatefromstring($wmData) : null;
if (! $watermark) {
imagedestroy($src);
return false;
}
imagesavealpha($src, true);
imagesavealpha($watermark, true);
$srcW = imagesx($src);
$srcH = imagesy($src);
$wmW = imagesx($watermark);
$wmH = imagesy($watermark);
if ($srcW <= 0 || $srcH <= 0 || $wmW <= 0 || $wmH <= 0) {
imagedestroy($src);
imagedestroy($watermark);
return false;
}
$scale = max(0.05, min(1.0, (float) ($config['scale'] ?? 0.2)));
$targetW = max(1, (int) round($srcW * $scale));
$targetH = max(1, (int) round($wmH * ($targetW / $wmW)));
$resized = imagecreatetruecolor($targetW, $targetH);
imagealphablending($resized, false);
imagesavealpha($resized, true);
imagecopyresampled($resized, $watermark, 0, 0, 0, 0, $targetW, $targetH, $wmW, $wmH);
imagedestroy($watermark);
$padding = max(0, (int) ($config['padding'] ?? 0));
$position = $config['position'] ?? 'bottom-right';
$x = $padding;
$y = $padding;
if ($position === 'top-right') {
$x = max(0, $srcW - $targetW - $padding);
} elseif ($position === 'bottom-left') {
$y = max(0, $srcH - $targetH - $padding);
} elseif ($position === 'bottom-right') {
$x = max(0, $srcW - $targetW - $padding);
$y = max(0, $srcH - $targetH - $padding);
} elseif ($position === 'center') {
$x = (int) max(0, ($srcW - $targetW) / 2);
$y = (int) max(0, ($srcH - $targetH) / 2);
}
$opacity = max(0.0, min(1.0, (float) ($config['opacity'] ?? 0.25)));
$mergeOpacity = (int) round($opacity * 100); // imagecopymerge uses 0-100
imagealphablending($src, true);
imagecopymerge($src, $resized, $x, $y, 0, 0, $targetW, $targetH, $mergeOpacity);
imagedestroy($resized);
// Overwrite original (respect mime: always JPEG for compatibility)
@imagejpeg($src, $fullSrc, 90);
imagedestroy($src);
return true;
} catch (\Throwable $e) {
return false;
}
}
/**
* Copy a source to destination and apply watermark there.
*/
public static function copyWithWatermark(string $disk, string $sourcePath, string $destPath, array $config): ?string
{
if (! Storage::disk($disk)->exists($sourcePath)) {
return null;
}
Storage::disk($disk)->makeDirectory(dirname($destPath));
Storage::disk($disk)->copy($sourcePath, $destPath);
$applied = self::applyWatermarkOnDisk($disk, $destPath, $config);
return $applied ? $destPath : null;
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Support;
use App\Models\Event;
use Illuminate\Support\Arr;
class WatermarkConfigResolver
{
public static function determineBrandingAllowed(Event $event): bool
{
$event->loadMissing('eventPackage.package');
return $event->eventPackage?->package?->branding_allowed === true;
}
public static function determinePolicy(Event $event): string
{
$event->loadMissing('eventPackage.package');
return $event->eventPackage?->package?->watermark_allowed === false ? 'none' : 'basic';
}
/**
* @return array{type:string, policy:string, asset?:string, position?:string, opacity?:float, scale?:float, padding?:int}
*/
public static function resolve(Event $event): array
{
$policy = self::determinePolicy($event);
if ($policy === 'none') {
return [
'type' => 'none',
'policy' => $policy,
];
}
$baseSetting = WatermarkSetting::query()->first();
$base = [
'asset' => $baseSetting?->asset ?? config('watermark.base.asset', 'branding/fotospiel-watermark.png'),
'position' => $baseSetting?->position ?? config('watermark.base.position', 'bottom-right'),
'opacity' => $baseSetting?->opacity ?? config('watermark.base.opacity', 0.25),
'scale' => $baseSetting?->scale ?? config('watermark.base.scale', 0.2),
'padding' => $baseSetting?->padding ?? config('watermark.base.padding', 16),
];
$event->loadMissing('eventPackage.package', 'tenant');
$brandingAllowed = self::determineBrandingAllowed($event);
$eventWatermark = Arr::get($event->settings, 'watermark', []);
$tenantWatermark = Arr::get($event->tenant?->settings, 'watermark', []);
$serveOriginals = (bool) Arr::get($event->settings, 'watermark_serve_originals', false);
$mode = $brandingAllowed
? ($eventWatermark['mode'] ?? $tenantWatermark['mode'] ?? 'base')
: 'base';
if ($mode === 'off' && $policy === 'basic') {
$mode = 'base';
}
if ($mode === 'off') {
return [
'type' => 'none',
'policy' => $policy,
];
}
$source = $mode === 'custom' && $brandingAllowed ? ($eventWatermark ?: $tenantWatermark) : [];
$asset = $source['asset'] ?? $base['asset'] ?? null;
$position = $source['position'] ?? $base['position'] ?? 'bottom-right';
$opacity = (float) ($source['opacity'] ?? $base['opacity'] ?? 0.25);
$scale = (float) ($source['scale'] ?? $base['scale'] ?? 0.2);
$padding = (int) ($source['padding'] ?? $base['padding'] ?? 16);
$clamp = static fn (float $value, float $min, float $max) => max($min, min($max, $value));
return [
'type' => $mode === 'custom' && $brandingAllowed ? 'custom' : 'base',
'policy' => $policy,
'asset' => $asset,
'position' => $position,
'opacity' => $clamp($opacity, 0.0, 1.0),
'scale' => $clamp($scale, 0.05, 1.0),
'padding' => max(0, $padding),
'serve_originals' => $serveOriginals,
];
}
}
use App\Models\WatermarkSetting;