diff --git a/app/Http/Controllers/Admin/AdminSettingsController.php b/app/Http/Controllers/Admin/AdminSettingsController.php index 8a4b04025..c0222a2ac 100644 --- a/app/Http/Controllers/Admin/AdminSettingsController.php +++ b/app/Http/Controllers/Admin/AdminSettingsController.php @@ -603,7 +603,7 @@ trait AdminSettingsController $mediaTypes = $request->input('media_types'); $mediaArray = explode(',', $mediaTypes); foreach ($mediaArray as $mediaType) { - if (! in_array($mediaType, ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/heic', 'video/mp4', 'video/mov'])) { + if (! in_array($mediaType, ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/heic', 'video/mp4', 'video/quicktime', 'video/x-m4v'])) { return redirect()->back()->withErrors(['media_types' => 'Invalid media type']); } } diff --git a/app/Http/Controllers/Api/ApiV2Controller.php b/app/Http/Controllers/Api/ApiV2Controller.php index 900a2611c..fba7d4ced 100644 --- a/app/Http/Controllers/Api/ApiV2Controller.php +++ b/app/Http/Controllers/Api/ApiV2Controller.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use App\Jobs\ImageOptimizePipeline\ImageOptimize; use App\Jobs\MediaPipeline\MediaDeletePipeline; use App\Jobs\VideoPipeline\VideoThumbnail; +use App\Jobs\VideoPipeline\VideoTranscode; use App\Media; use App\Services\AccountService; use App\Services\InstanceService; @@ -323,6 +324,13 @@ class ApiV2Controller extends Controller $preview_url = '/storage/no-preview.png'; $url = '/storage/no-preview.png'; break; + + case 'video/quicktime': + case 'video/x-m4v': + VideoTranscode::dispatch($media)->onQueue('mmo'); + $preview_url = '/storage/no-preview.png'; + $url = '/storage/no-preview.png'; + break; } $user->storage_used = (int) $updatedAccountSize; diff --git a/app/Http/Controllers/ComposeController.php b/app/Http/Controllers/ComposeController.php index 849209988..38fcc5631 100644 --- a/app/Http/Controllers/ComposeController.php +++ b/app/Http/Controllers/ComposeController.php @@ -8,6 +8,7 @@ use App\Hashtag; use App\Jobs\ImageOptimizePipeline\ImageOptimize; use App\Jobs\StatusPipeline\NewStatusPipeline; use App\Jobs\VideoPipeline\VideoThumbnail; +use App\Jobs\VideoPipeline\VideoTranscode; use App\Media; use App\MediaTag; use App\Models\Poll; @@ -144,6 +145,13 @@ class ComposeController extends Controller $url = '/storage/no-preview.png'; break; + case 'video/quicktime': + case 'video/x-m4v': + VideoTranscode::dispatch($media)->onQueue('mmo'); + $preview_url = '/storage/no-preview.png'; + $url = '/storage/no-preview.png'; + break; + default: break; } diff --git a/app/Jobs/StoryPipeline/StoryFetch.php b/app/Jobs/StoryPipeline/StoryFetch.php index acf65d578..d2ddd2808 100644 --- a/app/Jobs/StoryPipeline/StoryFetch.php +++ b/app/Jobs/StoryPipeline/StoryFetch.php @@ -685,8 +685,8 @@ class StoryFetch implements ShouldQueue 'image/avif' => ['avif'], 'video/mp4' => ['mp4'], 'video/webm' => ['webm'], - 'video/mov' => ['mov'], 'video/quicktime' => ['mov', 'qt'], + 'video/x-m4v' => ['m4v'], ]; foreach ($mimeTypes as $mimeType) { diff --git a/app/Jobs/VideoPipeline/VideoTranscode.php b/app/Jobs/VideoPipeline/VideoTranscode.php new file mode 100644 index 000000000..5ddcfb477 --- /dev/null +++ b/app/Jobs/VideoPipeline/VideoTranscode.php @@ -0,0 +1,123 @@ +media->id; + } + + public function middleware(): array + { + return [(new WithoutOverlapping("media:video-transcode:id-{$this->media->id}"))->shared()->dontRelease()]; + } + + public function __construct(Media $media) + { + $this->media = $media; + } + + public function handle() + { + $media = $this->media; + + if ($media->mime === 'video/mp4') { + VideoThumbnail::dispatch($media)->onQueue('mmo'); + + return; + } + + if (! in_array($media->mime, self::TRANSCODABLE_MIMES, true)) { + return; + } + + $sourcePath = $media->media_path; + $info = pathinfo($sourcePath); + $targetPath = ($info['dirname'] === '.' ? '' : $info['dirname'].'/').$info['filename'].'.mp4'; + + if ($targetPath === $sourcePath) { + $targetPath = ($info['dirname'] === '.' ? '' : $info['dirname'].'/').$info['filename'].'_pf.mp4'; + } + + try { + $format = new X264('aac', 'libx264'); + $format->setAdditionalParameters(['-movflags', '+faststart']); + + FFMpeg::open($sourcePath) + ->export() + ->toDisk('local') + ->inFormat($format) + ->save($targetPath); + + $originalPath = $media->media_path; + $media->media_path = $targetPath; + $media->mime = 'video/mp4'; + + if (Storage::disk('local')->exists($targetPath)) { + $media->size = Storage::disk('local')->size($targetPath); + } + + $media->save(); + + try { + if (Storage::disk('local')->exists($originalPath)) { + Storage::disk('local')->delete($originalPath); + } + } catch (\Exception $cleanupException) { + if (config('app.dev_log')) { + Log::warning('Video transcode cleanup failed: '.$cleanupException->getMessage()); + } + } + + if ($media->status_id) { + Cache::forget('status:transformer:media:attachments:'.$media->status_id); + MediaService::del($media->status_id); + StatusService::del($media->status_id); + } + + VideoThumbnail::dispatch($media)->onQueue('mmo'); + } catch (\Exception $e) { + if (config('app.dev_log')) { + Log::error('Video transcode failed for media '.$media->id.': '.$e->getMessage()); + } + + throw $e; + } + } +} diff --git a/resources/assets/components/admin/AdminSettings.vue b/resources/assets/components/admin/AdminSettings.vue index deab138e0..93df9b941 100644 --- a/resources/assets/components/admin/AdminSettings.vue +++ b/resources/assets/components/admin/AdminSettings.vue @@ -1034,7 +1034,7 @@ } if(this.mediaTypes.mov) { - res += 'video/mov,' + res += 'video/quicktime,' } if(res.endsWith(',')) { @@ -1096,12 +1096,24 @@ }, setMediaTypes() { + const mimeToKey = { + 'image/jpeg': 'jpeg', + 'image/png': 'png', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/avif': 'avif', + 'image/heic': 'heic', + 'video/mp4': 'mp4', + 'video/quicktime': 'mov', + 'video/x-m4v': 'mov', + 'video/mov': 'mov', + }; const types = this.media.media_types.split(','); if(types && types.length) { types.forEach((type) => { - let mime = type.split('/')[1]; - if(['jpeg', 'png', 'gif', 'webp', 'avif', 'heic', 'mp4', 'mov'].includes(mime)) { - this.mediaTypes[mime] = true; + const key = mimeToKey[type]; + if(key) { + this.mediaTypes[key] = true; } }) }