Media: support .mov uploads via on-ingest transcode to mp4

Pixelfed's media allowlist exposed `video/mov`, but PHP/finfo reports `video/quicktime`
for actual .mov files. Even when the mime cleared validation, the post-upload pipeline
only dispatched processing for `video/mp4`, leaving quicktime media without a thumbnail
or playable URL.

This fixes both halves:
- Replace the bogus `video/mov` string with `video/quicktime` (and add `video/x-m4v`)
  in the admin allowlist (Vue + server validator) and StoryFetch's mime-to-extension
  table. Admin checkbox parser maps `video/quicktime`/`video/x-m4v` (and the legacy
  `video/mov`) back onto the existing `mov` key for backwards compat.
- Add `VideoPipeline\VideoTranscode` job that remuxes/encodes quicktime/m4v to H.264+AAC
  mp4 (with `+faststart` for web playback), updates the Media row, and chains into the
  existing `VideoThumbnail` job. ComposeController and ApiV2Controller dispatch it for
  `video/quicktime` and `video/x-m4v` uploads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pull/6602/head
Rod 2 months ago
parent 3a71cda51f
commit 7e1034d649

@ -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']);
}
}

@ -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;

@ -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;
}

@ -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) {

@ -0,0 +1,123 @@
<?php
namespace App\Jobs\VideoPipeline;
use App\Media;
use App\Services\MediaService;
use App\Services\StatusService;
use Cache;
use FFMpeg;
use FFMpeg\Format\Video\X264;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
use Log;
class VideoTranscode implements ShouldBeUniqueUntilProcessing, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
const TRANSCODABLE_MIMES = ['video/quicktime', 'video/x-m4v'];
protected $media;
public $timeout = 1800;
public $tries = 2;
public $maxExceptions = 1;
public $failOnTimeout = true;
public $deleteWhenMissingModels = true;
public $uniqueFor = 7200;
public function uniqueId(): string
{
return 'media:video-transcode:id-'.$this->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;
}
}
}

@ -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;
}
})
}

Loading…
Cancel
Save