Implement our own quicktime parser

This commit is contained in:
Deon George 2024-09-16 22:10:19 +10:00
parent a3013078e0
commit f9cdc8f9d2
42 changed files with 1367 additions and 62 deletions

View File

@ -39,6 +39,6 @@ class CatalogScan extends Command
$o = $class::findOrFail($this->argument('id')); $o = $class::findOrFail($this->argument('id'));
return Job::dispatchSync($o); return Job::dispatchSync($o,$this->option('dirty'));
} }
} }

View File

@ -39,11 +39,6 @@ class VideoController extends Controller
]); ]);
} }
public function info(Video $o)
{
return view('video.view',['o'=>$o]);
}
public function undelete(Video $o) public function undelete(Video $o)
{ {
$o->remove = NULL; $o->remove = NULL;

View File

@ -91,10 +91,12 @@ class CatalogScan implements ShouldQueue, ShouldBeUnique
} }
// If the file signature changed, abort the update. // If the file signature changed, abort the update.
if ($this->o->getOriginal('file_signature') && $this->o->wasChanged('file_signature')) { if ($this->o->getOriginal('file_signature') && $this->o->wasChanged('file_signature'))
dump(['old'=>$this->o->getOriginal('file_signature'),'new'=>$this->o->file_signature]); throw new \Exception(sprintf('File Signature Changed for [%s] DB: [%s], File: [%s]?',
abort(500,'File Signature Changed?'); $this->o->file_name(),
} $this->o->file_signature,
$this->o->getOriginal('file_signature'),
));
$this->o->save(); $this->o->save();
} }

42
app/Media/Base.php Normal file
View File

@ -0,0 +1,42 @@
<?php
namespace App\Media;
use Illuminate\Support\Facades\Log;
abstract class Base
{
protected const BLOCK_SIZE = 4096;
/** Full path to the file */
protected string $filename;
protected int $filesize;
protected string $type;
public function __construct(string $filename,string $type)
{
Log::info(sprintf('Create a media type [%s] for [%s]',get_class($this),$filename));
$this->filename = $filename;
$this->filesize = filesize($filename);
$this->type = $type;
}
/**
* Enable getting values for keys in the response
*
* @param string $key
* @return mixed|object
* @throws \Exception
*/
public function __get(string $key): mixed
{
switch ($key) {
case 'type':
return $this->type;
default:
throw new \Exception('Unknown key: '.$key);
}
}
}

30
app/Media/Factory.php Normal file
View File

@ -0,0 +1,30 @@
<?php
namespace App\Media;
use Illuminate\Support\Arr;
class Factory {
private const LOGKEY = 'MF-';
/**
* @var array event type to event class mapping
*/
public const map = [
'video/quicktime' => QuickTime::class,
];
/**
* Returns new event instance
*
* @param string $file
* @return Base
*/
public static function create(string $file): Base
{
$type = mime_content_type($file);
$class = Arr::get(self::map,$type,Unknown::class);
return new $class($file,$type);
}
}

121
app/Media/QuickTime.php Normal file
View File

@ -0,0 +1,121 @@
<?php
namespace App\Media;
use Illuminate\Support\Collection;
use App\Media\QuickTime\Atoms\{mdat,moov,Unknown};
use App\Traits\FindQuicktimeAtoms;
// https://developer.apple.com/documentation/quicktime-file-format/quicktime_movie_files
class QuickTime extends Base {
use FindQuicktimeAtoms;
private const LOGKEY = 'MFQ';
private Collection $atoms;
private const atom_classes = 'App\\Media\\QuickTime\\Atoms\\';
public function __construct(string $filename,string $type)
{
parent::__construct($filename,$type);
// Parse the atoms
$this->atoms = $this->get_atoms(self::atom_classes,Unknown::class,0,$this->filesize);
}
public function __get(string $key): mixed
{
switch ($key) {
case 'audio_channels':
case 'audio_codec':
case 'audio_samplerate':
return $this->getAudioAtoms()
->map(fn($item)=>$item->find_atoms(moov\trak\mdia\minf\stbl\stsd::class,1))
->flatten()
->map(fn($item)=>$item->{$key})
->join(',');
// Signatures are calculated by the sha of the MDAT atom.
case 'signature':
$atom = $this->find_atoms(mdat::class,1);
return $atom->signature;
// Creation Time is in the MOOV/MVHD atom
case 'creation_date':
case 'duration':
case 'preferred_rate':
case 'preferred_volume':
// Height/Width is in the moov/trak/tkhd attom
case 'height':
case 'width':
$atom = $this->find_atoms(moov::class,1);
return $atom->{$key};
case 'gps_altitude':
case 'gps_lat':
case 'gps_lon':
$atom = $this->find_atoms(moov::class,1)
->find_atoms(moov\meta::class,1);
return $atom->{$key};
case 'time_scale':
$atom = $this->find_atoms(moov\mvhd::class,1);
return $atom->{$key};
case 'type':
return parent::__get($key);
case 'video_codec':
return $this->getVideoAtoms()
->map(fn($item)=>$item->find_atoms(moov\trak\mdia\minf\stbl\stsd::class,1))
->flatten()
->map(fn($item)=>$item->{$key})
->join(',');
case 'video_framerate':
$atom = $this->getVideoAtoms()
->map(fn($item)=>$item->find_atoms(moov\trak\mdia\minf\stbl\stts::class,1))
->pop();
return $atom->frame_rate($this->time_scale);
default:
throw new \Exception('Unknown key: '.$key);
}
}
/**
* Find all the audio track atoms
*
* Audio atoms are in moov/trak/mdia/minf
*
* @return Collection
* @throws \Exception
*/
public function getAudioAtoms(): Collection
{
return $this->find_atoms(moov\trak\mdia\minf::class)
->filter(fn($item)=>$item->type==='soun');
}
/**
* Find all the video track atoms
*
* Audio atoms are in moov/trak/mdia/minf
*
* @return Collection
* @throws \Exception
*/
public function getVideoAtoms(): Collection
{
return $this->find_atoms(moov\trak\mdia\minf::class)
->filter(fn($item)=>$item->type==='vide');
}
}

View File

@ -0,0 +1,144 @@
<?php
namespace App\Media\QuickTime;
use Illuminate\Support\Collection;
use App\Media\QuickTime\Atoms\moov\{mvhd,trak};
use App\Traits\FindQuicktimeAtoms;
abstract class Atom
{
use FindQuicktimeAtoms;
protected const record_size = 16384;
protected const BLOCK_SIZE = 4096;
protected int $offset;
protected int $size;
protected string $filename;
private mixed $fh;
private int $fp;
protected Collection $cache;
protected Collection $atoms;
public function __construct(int $offset,int $size,string $filename)
{
$this->offset = $offset;
// Quick validation
if ($size < 0)
throw new \Exception(sprintf('Atom cannot be negative. (%d)',$size));
$this->size = $size;
$this->filename = $filename;
$this->cache = collect();
}
public function __get(string $key): mixed
{
switch ($key) {
// Create time is in the MOOV/MVHD atom
case 'creation_date':
case 'duration':
case 'preferred_rate':
case 'preferred_volume':
$subatom = $this->find_atoms(mvhd::class,1);
return $subatom->{$key};
// Height is in the moov/trak/tkhd attom
case 'height':
// Width is in the moov/trak/tkhd attom
case 'width':
$atom = $this->find_atoms(trak::class);
return $atom->map(fn($item)=>$item->{$key})->filter()->max();
// Signatures are calculated by the sha of the MDAT atom.
case 'signature':
return $this->signature();
default:
throw new \Exception('Unknown key: '.$key);
}
}
protected function data(): string
{
// Quick validation
if ($this->size > self::record_size)
throw new \Exception(sprintf('Refusing to read more than %d of data',self::record_size));
$data = '';
if ($this->fopen()) {
while (! is_null($read=$this->fread()))
$data .= $read;
$this->fclose();
}
return $data;
}
protected function fclose(): bool
{
fclose($this->fh);
unset($this->fh);
return TRUE;
}
/**
* Open the file and seek to the atom
*
* @return bool
*/
protected function fopen(): bool
{
$this->fh = fopen($this->filename,'r');
fseek($this->fh,$this->offset);
$this->fp = 0;
return TRUE;
}
/**
* Read the atom from the file
*
* @param int $size
* @return string|NULL
*/
protected function fread(int $size=4096): ?string
{
if ($this->fp === $this->size)
return NULL;
if ($this->fp+$size > $this->size)
$size = $this->size-$this->fp;
$read = fread($this->fh,$size);
$this->fp += $size;
return $read;
}
protected function fseek(int $offset): int
{
$this->fp = $offset;
return fseek($this->fh,$this->offset+$this->fp);
}
protected function unpack(array $unpack=[]): string
{
return collect($unpack ?: static::unpack)->map(fn($v,$k)=>$v[0].$k)->join('/');
}
protected function unpack_size(array $unpack=[]): int
{
return collect($unpack ?: static::unpack)->map(fn($v,$k)=>$v[1])->sum();
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace App\Media\QuickTime\Atoms;
use Illuminate\Support\Collection;
use Leenooks\Traits\ObjectIssetFix;
use App\Media\QuickTime\Atom;
use App\Media\QuickTime\Atoms\moov\trak\tkhd;
abstract class SubAtom extends Atom
{
use ObjectIssetFix;
protected ?string $unused_data;
protected const atom_record = [
'version'=>['c',1],
'flags'=>['a3',3],
'count'=>['N',4],
];
public function __get(string $key): mixed
{
switch ($key) {
// Height is in the moov/trak/tkhd attom
case 'height':
// Width is in the moov/trak/tkhd attom
case 'width':
$atom = $this->find_atoms(tkhd::class,1);
return $atom->{$key};
default:
throw new \Exception('Unknown key: '.$key);
}
}
/**
* Unpack data into our cache
*
* @param string|null $data
* @return Collection
* @throws \Exception
*/
protected function cache(?string $data=NULL): Collection
{
$data = $data ?: $this->data();
if (! count($this->cache) && $this->size) {
$this->cache = collect(unpack($this->unpack(),$data));
if ($this->size > ($x=$this->unpack_size()))
$this->unused_data = substr($data,$x);
}
return $this->cache;
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Media\QuickTime\Atoms;
// An atom we dont know how to handle
use App\Media\QuickTime\Atom;
class Unknown extends Atom
{
private string $atom;
public function __construct(int $offset,int $size,string $filename,string $atom,?string $data)
{
parent::__construct($offset,$size,$filename,$data);
$this->atom = $atom;
// For debugging
if (FALSE)
$this->debug = hex_dump($data ?: $this->data());
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Media\QuickTime\Atoms;
// Unused space available in file.
use App\Media\QuickTime\Atom;
class free extends Atom
{
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Media\QuickTime\Atoms;
// File type compatibility—identifies the file type and differentiates it from similar file types,
// such as MPEG-4 files and JPEG-2000 files.
// The file type atom has an atom type value of 'ftyp' and contains the following fields:
// Size, Type, Major brand, Minor version, Compatible brands.
use Illuminate\Support\Arr;
use App\Media\QuickTime\Atom;
class ftyp extends Atom
{
protected const unpack = [
'major'=>['a4',1],
'minor'=>['a4',4],
'compat'=>['a4',4],
];
public function __construct(int $offset,int $size,string $filename,?string $data) {
if ($size > 12)
throw new \Exception('FTYP atom larger than 12 bytes, we wont be able to handled that');
parent::__construct($offset,$size,$filename);
$this->cache['data'] = unpack($this->unpack(),$data);
if (Arr::get($this->cache,'data.compat') !== 'qt ')
throw new \Exception('This is not a QT format file');
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Media\QuickTime\Atoms;
// Movie sample data—media samples such as video frames and groups of audio samples.
// Usually this data can be interpreted only by using the movie resource.
use Illuminate\Support\Arr;
use App\Media\QuickTime\Atom;
class mdat extends Atom
{
/**
* Calculate the signature of the data
*
* @param string $alg
* @return string
*/
public function signature(string $alg='sha1'): string
{
if (! Arr::has($this->cache,'signature')) {
if ($this->size) {
$this->fopen();
$hash = hash_init($alg);
while (!is_null($read = $this->fread(16384)))
hash_update($hash, $read);
$this->fclose();
$this->cache['signature'] = hash_final($hash);
} else {
$this->cache['signature'] = NULL;
}
}
return $this->cache['signature'];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Media\QuickTime\Atoms;
// Movie resource metadata about the movie (number and type of tracks, location of sample data, and so on).
// Describes where the movie data can be found and how to interpret it.
use App\Media\QuickTime\Atom;
class moov extends Atom
{
private const subatom_classes = 'App\\Media\\QuickTime\\Atoms\\moov\\';
public function __construct(int $offset,int $size,string $filename,?string $data) {
parent::__construct($offset,$size,$filename);
$this->atoms = $this->get_atoms(self::subatom_classes,Unknown::class,$offset,$size,$data);
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Media\QuickTime\Atoms\moov;
// Unused space available in file.
use App\Media\QuickTime\Atoms\SubAtom;
class free extends SubAtom
{
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Media\QuickTime\Atoms\moov;
use Illuminate\Support\Arr;
use App\Media\QuickTime\Atoms\moov\meta\{ilst,keys};
use App\Media\QuickTime\Atoms\{SubAtom,Unknown};
class meta extends SubAtom
{
private const subatom_classes = 'App\\Media\\QuickTime\\Atoms\\moov\\meta\\';
public function __construct(int $offset,int $size,string $filename,?string $data)
{
parent::__construct($offset,$size,$filename);
$this->atoms = $this->get_atoms(self::subatom_classes,Unknown::class,$offset,$size,$data);
$keys = $this->find_atoms(keys::class,1);
$values = $this->find_atoms(ilst::class,1);
$this->cache = $keys->cache->combine($values->cache);
}
public function __get(string $key): mixed
{
switch ($key) {
case 'gps':
$m = [];
$gps = Arr::get($this->cache,'mdta.com.apple.quicktime.location.ISO6709');
preg_match('/^([+-][0-9]{2,6}(?:\.[0-9]+)?)([+-][0-9]{3,7}(?:\.[0-9]+)?)([+-][0-9]+(?:\.[0-9]+)?)?/',$gps,$m);
return ['lat'=>(float)$m[1],'lon'=>(float)$m[2],'alt'=>(float)$m[3]];
case 'gps_altitude':
return Arr::get($this->gps,'alt');
case 'gps_lat':
return Arr::get($this->gps,'lat');
case 'gps_lon':
return Arr::get($this->gps,'lon');
default:
return parent::__get($key);
}
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Media\QuickTime\Atoms\moov\meta;
// Unused space available in file.
use App\Media\QuickTime\Atoms\SubAtom;
class free extends SubAtom
{
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Media\QuickTime\Atoms\moov\meta;
// Item LiST container atom
use App\Media\QuickTime\Atoms\SubAtom;
class ilst extends SubAtom
{
public function __construct(int $offset,int $size,string $filename,?string $data)
{
parent::__construct($offset,$size,$filename);
$ptr = 0;
while ($ptr < strlen($data)) {
$key_size = unpack('Nsize',substr($data,$ptr,4));
// Sometimes atoms are terminated with a 0
if ($key_size['size'] === 0) {
$ptr += 4;
continue;
}
$a = unpack(sprintf('a4name/a%ddata',$key_size['size']-8),substr($data,$ptr+4,4+$key_size['size']-8));
$ptr += $key_size['size'];
// If we didnt get the right amount of data, something is wrong.
if (strlen($a['data']) < $key_size['size']-8)
break;
$b = unpack(sprintf('Nsize/a4name/a%ddata',strlen($a['data'])-8),$a['data']);
if ($b['name'] !== 'data')
throw new \Exception('Parsing of ILST got data that wasnt expected');
$c = unpack(sprintf('a4language/a4unknown/a%ddata',strlen($b['data'])-8),$b['data']);
// heirachy, name, size, offset
$this->cache = $this->cache->push($c['data']);
}
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Media\QuickTime\Atoms\moov\meta;
// The metadata item keys atom holds a list of the metadata keys that may be present in the metadata atom.
// This list is indexed starting with 1; 0 is a reserved index value. The metadata item keys atom is a full atom with an atom type of "keys".
use App\Media\QuickTime\Atoms\SubAtom;
class keys extends SubAtom
{
public function __construct(int $offset,int $size,string $filename,?string $data)
{
parent::__construct($offset,$size,$filename);
$this->keys = collect();
$read = unpack($this->unpack(self::atom_record),substr($data,0,$ptr=$this->unpack_size(self::atom_record)));
for ($i=0; $i<$read['count']; $i++) {
$key_size = unpack('Nsize',substr($data,$ptr,4));
$keys = unpack(sprintf('a4namespace/a%dname',$key_size['size']-8),substr($data,$ptr+4,4+$key_size['size']-8));
$ptr += $key_size['size'];
$this->cache = $this->cache->push(sprintf('%s.%s',$keys['namespace'],$keys['name']));
}
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Media\QuickTime\Atoms\moov;
// Specifies the characteristics of an entire QuickTime movie.
use Carbon\Carbon;
use Illuminate\Support\Arr;
use App\Media\QuickTime\Atoms\SubAtom;
class mvhd extends SubAtom
{
protected const unpack = [
'version'=>['c',1],
'flags'=>['a3',3],
'create'=>['N',4],
'modified'=>['N',4],
'time_scale'=>['N',4],
'duration'=>['N',4],
'prate'=>['a4',4],
'pvolume'=>['a2',2],
'matrix'=>['a36',36],
'prevtime'=>['N',4],
'prevdur'=>['N',4],
'postertime'=>['N',4],
'selecttime'=>['N',4],
'selectdur'=>['N',4],
'curtime'=>['N',4],
'nexttrack'=>['N',4],
];
public function __get($key): mixed
{
switch ($key) {
case 'creation_date':
// We need to convert from MAC time 1904-01-01 to epoch
return Carbon::createFromTimestamp(Arr::get($this->cache(),'create')-2082844800);
case 'duration':
return ($x=$this->time_scale) ? round(Arr::get($this->cache(),'duration')/$x,3) : NULL;
case 'preferred_rate':
return fixed_point_int_to_float(Arr::get($this->cache(),'prate'));
case 'preferred_volume':
return fixed_point_int_to_float(Arr::get($this->cache(),'pvolume'));
case 'time_scale':
return round(Arr::get($this->cache(),$key));
default:
return parent::__get($key);
}
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Media\QuickTime\Atoms\moov;
// An atom that defines a single track of a movie
use App\Media\QuickTime\Atoms\SubAtom;
use App\Media\QuickTime\Atoms\Unknown;
class trak extends SubAtom
{
private const subatom_classes = 'App\\Media\\QuickTime\\Atoms\\moov\\trak\\';
public function __construct(int $offset,int $size,string $filename,?string $data) {
parent::__construct($offset,$size,$filename);
$this->atoms = $this->get_atoms(self::subatom_classes,Unknown::class,$offset,$size,$data);
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Media\QuickTime\Atoms\moov\trak;
use App\Media\QuickTime\Atoms\moov\trak\mdia\hdlr;
use App\Media\QuickTime\Atoms\SubAtom;
use App\Media\QuickTime\Atoms\Unknown;
class mdia extends SubAtom
{
private const subatom_classes = 'App\\Media\\QuickTime\\Atoms\\moov\\trak\\mdia\\';
public function __construct(int $offset,int $size,string $filename,?string $data)
{
parent::__construct($offset,$size,$filename);
// We'll find atoms
$this->atoms = $this->get_atoms(
self::subatom_classes,
Unknown::class,
$offset,
$size,
$data,
NULL,
fn($atom)=>($atom instanceof hdlr) ? $atom->cache['csubtype'] : NULL
);
// For debugging
if (FALSE)
$this->debug = hex_dump($data ?: $this->data());
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Media\QuickTime\Atoms\moov\trak\mdia;
use App\Media\QuickTime\Atoms\SubAtom;
class hdlr extends SubAtom
{
protected const unpack = [
'version'=>['c',1],
'flags'=>['a3',3],
'ctype'=>['a4',4],
'csubtype'=>['a4',4],
'cmanufact'=>['a4',4],
'cflags'=>['a4',4],
'cmask'=>['a4',4],
];
public function __construct(int $offset,int $size,string $filename,?string $data)
{
parent::__construct($offset,$size,$filename);
$this->cache = $this->cache();
$this->cache['name'] = pascal_string($this->unused_data);
$this->unused_data = (($x=strlen($this->cache['name'])+1) < strlen($this->unused_data)) ? substr($data,$x) : NULL;
// For debugging
if (FALSE)
$this->debug = hex_dump($data ?: $this->data());
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Media\QuickTime\Atoms\moov\trak\mdia;
use Leenooks\Traits\ObjectIssetFix;
use App\Media\QuickTime\Atoms\SubAtom;
use App\Media\QuickTime\Atoms\Unknown;
class minf extends SubAtom
{
use ObjectIssetFix;
private const subatom_classes = 'App\\Media\\QuickTime\\Atoms\\moov\\trak\\mdia\\minf\\';
protected ?string $type;
public function __construct(int $offset,int $size,string $filename,?string $data,string $arg=NULL)
{
parent::__construct($offset,$size,$filename);
$this->type = $arg;
$this->atoms = $this->get_atoms(self::subatom_classes,Unknown::class,$offset,$size,$data,$arg);
// For debugging
if (FALSE)
$this->debug = hex_dump($data ?: $this->data());
}
public function __get(string $key): mixed
{
switch ($key) {
case 'type':
return $this->{$key};
default:
return parent::__get($key);
}
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Media\QuickTime\Atoms\moov\trak\mdia\minf;
use App\Media\QuickTime\Atoms\moov\trak\mdia\hdlr as MdiaHdlr;
class hdlr extends MdiaHdlr
{
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Media\QuickTime\Atoms\moov\trak\mdia\minf;
use App\Media\QuickTime\Atoms\SubAtom;
use App\Media\QuickTime\Atoms\Unknown;
class stbl extends SubAtom
{
private const subatom_classes = 'App\\Media\\QuickTime\\Atoms\\moov\\trak\\mdia\\minf\\stbl\\';
public function __construct(int $offset,int $size,string $filename,?string $data,?string $arg=NULL)
{
parent::__construct($offset,$size,$filename);
$this->atoms = $this->get_atoms(self::subatom_classes,Unknown::class,$offset,$size,$data,$arg);
// For debugging
if (FALSE)
$this->debug = hex_dump($data ?: $this->data());
}
}

View File

@ -0,0 +1,104 @@
<?php
namespace App\Media\QuickTime\Atoms\moov\trak\mdia\minf\stbl;
use Illuminate\Support\Arr;
use App\Media\QuickTime\Atoms\SubAtom;
class stsd extends SubAtom
{
protected const unpack = [
'size'=>['N',4],
'format'=>['a4',4],
'reserved'=>['a6',6],
'index'=>['n',2],
'encoder_version'=>['n',2],
'encoder_revision'=>['n',2],
'encoder_vendor'=>['a4',4],
];
protected const audio_record = [
'audio_channels'=>['n',2],
'audio_bit_depth'=>['n',2],
'audio_compression_id'=>['n',2],
'audio_packet_size'=>['n',2],
'audio_sample_rate'=>['a4',4],
];
protected const video_record = [
'temporal_quality'=>['N',4],
'spatial_quality'=>['N',4],
'width'=>['n',2],
'height'=>['n',2],
'resolution_x'=>['a4',4],
'resolution_y'=>['a4',4],
'data_size'=>['N',4],
'frame_count'=>['n',2],
//'codec_name'=>['a4',4], // pascal string
];
public function __construct(int $offset,int $size,string $filename,?string $data,string $arg=NULL)
{
parent::__construct($offset,$size,$filename);
$this->type = $arg;
$read = unpack($this->unpack(self::atom_record),substr($data,0,$ptr=$this->unpack_size(self::atom_record)));
for ($i=0; $i<$read['count']; $i++) {
$this->cache = collect(unpack($this->unpack(),substr($data,$ptr,$x=$this->unpack_size())));
$ptr += $x;
switch ($this->type) {
case 'soun':
// Audio Track
$this->audio = unpack($this->unpack(self::audio_record),substr($data,$ptr,$x=$this->unpack_size(self::audio_record)));
$ptr += $x;
break;
case 'vide':
// Video Track
$this->video = unpack($this->unpack(self::video_record),substr($data,$ptr,$x=$this->unpack_size(self::video_record)));
$ptr += $x;
// codec - pascal string
$this->video['codec'] = pascal_string(substr($data,$ptr));
$ptr += strlen($this->video['codec'])+1;
}
$this->extra = substr($data,$ptr);
}
}
public function __get(string $key):mixed
{
switch ($key) {
case 'audio_channels':
return Arr::get($this->audio,$key);
case 'audio_codec':
switch ($this->cache->get('format')) {
case 'mp4a': return 'ISO/IEC 14496-3 AAC';
default:
return $this->cache->get('format');
}
case 'audio_samplerate':
return fixed_point_int_to_float(Arr::get($this->audio,'audio_sample_rate',0));
case 'video_codec':
return Arr::get($this->video,'codec');
case 'video_framerate':
dd($this);
// return Arr::get($this->video,'codec');
default:
return parent::__get($key);
}
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Media\QuickTime\Atoms\moov\trak\mdia\minf\stbl;
use App\Media\QuickTime\Atoms\SubAtom;
class stts extends SubAtom
{
public function __construct(int $offset,int $size,string $filename,?string $data)
{
parent::__construct($offset,$size,$filename);
$read = unpack($this->unpack(self::atom_record),substr($data,0,$ptr=$this->unpack_size(self::atom_record)));
for ($i=0; $i<$read['count']; $i++) {
$this->cache->push(unpack('Ncount/Nduration',substr($data,$ptr,8)));
$ptr += 8;
}
// For debugging
if (FALSE)
$this->debug = hex_dump($data ?: $this->data());
}
public function frame_rate(float $time_scale): float
{
return $this->cache
->pluck('duration')
->map(fn($item)=>$time_scale/$item)
->max();
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Media\QuickTime\Atoms\moov\trak;
use App\Media\QuickTime\Atoms\moov\meta as MoovMeta;
class meta extends MoovMeta
{
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Media\QuickTime\Atoms\moov\trak;
// An atom that specifies the characteristics of a single track within a movie
use Illuminate\Support\Arr;
use App\Media\QuickTime\Atoms\SubAtom;
class tkhd extends SubAtom
{
protected const unpack = [
'version'=>['c',1],
'flags'=>['a3',3],
'create'=>['N',4],
'modified'=>['N',4],
'trakid'=>['N',4], // The value 0 cannot be used
'reserved1'=>['a4',4],
'duration'=>['N',4],
'reserved2'=>['a8',8],
'layer'=>['n',2],
'altgroup'=>['n',2],
'volume'=>['a2',2], // 16 bit fixed point
'reserved3'=>['a2',2],
'matrix'=>['a36',36],
'twidth'=>['a4',4], // 32 bit fixed point
'theight'=>['a4',4], // 32 bit fixed point
];
public function __construct(int $offset,int $size,string $filename,?string $data)
{
parent::__construct($offset,$size,$filename,$data);
$this->cache = $this->cache();
// For debugging
if (FALSE)
$this->debug = hex_dump($data ?: $this->data());
}
public function __get($key): mixed
{
switch ($key) {
case 'height':
return fixed_point_int_to_float(Arr::get($this->cache(),'theight'));
case 'width':
return fixed_point_int_to_float(Arr::get($this->cache(),'twidth'));
default:
return parent::__get($key);
}
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Media\QuickTime\Atoms;
// Unused space available in file.
use App\Media\QuickTime\Atom;
class skip extends Atom
{
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Media\QuickTime\Atoms;
// Reserved space—can be overwritten by an extended size field if the following atom exceeds 2^32 bytes,
// without displacing the contents of the following atom.
use App\Media\QuickTime\Atom;
class wide extends Atom
{
}

24
app/Media/Unknown.php Normal file
View File

@ -0,0 +1,24 @@
<?php
namespace App\Media;
/**
* This represents a media type that we dont know how to parse
*/
class Unknown extends Base {
private const LOGKEY = 'MF?';
private string $type;
public function __construct(string $filename,string $type)
{
parent::__construct($filename);
$this->type = $type;
}
public function __get(string $key): mixed
{
return NULL;
}
}

View File

@ -4,10 +4,10 @@ namespace App\Models;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Traits\ForwardsCalls; use Illuminate\Support\Traits\ForwardsCalls;
use Imagick; use Imagick;
use App\Casts\PostgresBytea;
use App\Jobs\CatalogMove; use App\Jobs\CatalogMove;
class Photo extends Abstracted\Catalog class Photo extends Abstracted\Catalog
@ -16,9 +16,14 @@ class Photo extends Abstracted\Catalog
public const config = 'photo'; public const config = 'photo';
protected $casts = [
'created'=>'datetime:Y-m-d H:i:s',
'thumbnail'=>PostgresBytea::class,
];
protected static $includeSubSecTime = TRUE; protected static $includeSubSecTime = TRUE;
// Imagick Objectfile_name // Imagick Object
private ?Imagick $_o; private ?Imagick $_o;
protected array $init = [ protected array $init = [
'creation_date', 'creation_date',
@ -102,10 +107,10 @@ class Photo extends Abstracted\Catalog
if (isset($this->_o)) if (isset($this->_o))
return $this->_o; return $this->_o;
if ((!file_exists($this->file_name(FALSE))) || (!is_readable($this->file_name(FALSE)))) if ((! file_exists($this->file_name(FALSE))) || (! is_readable($this->file_name(FALSE))))
return $this->_o = NULL; return $this->_o = NULL;
if (!isset($this->_o)) if (! isset($this->_o))
return $this->_o = new Imagick($this->file_name(FALSE)); return $this->_o = new Imagick($this->file_name(FALSE));
} }

View File

@ -0,0 +1,104 @@
<?php
namespace App\Traits;
use Illuminate\Support\Collection;
use App\Media\QuickTime\Atom;
trait FindQuicktimeAtoms
{
protected function get_atoms(string $class_prefix,string $unknown,int $offset,int $size,string $atom=NULL,string $passthru=NULL,\Closure $callback=NULL): Collection
{
// List of atoms
// File Type atom should proceed move, movie data, preview. free space atoms, if it exists.
// Can assume it doesnt exist if those other atoms are discovered first.
// Atoms can be present in any order
// Must contain a movie atom.
// Movie data atoms can exceed 2^32.
$rp = 0;
if (! $atom) {
$fh = fopen($this->filename,'r');
fseek($fh,$offset);
}
$result = collect();
while ($rp < $size) {
$read = $atom ? substr($atom,$rp,8) : fread($fh,8);
$rp += strlen($read);
$header = unpack('Nsize/a4atom',$read);
// For mdat atoms, if size = 1, the true size is in the 64 bit extended header
if (($header['atom'] === 'mdat') && ($header['size'] === 1))
throw new \Exception(sprintf('%s:! We havent handed large QT files yet.',self::LOGKEY));
// Load our class for this supplier
$class = $class_prefix.$header['atom'];
$data = $atom
? substr($atom,$rp,$header['size']-8)
: ($header['size']-8 && ($header['size']-8 <= self::BLOCK_SIZE) ? fread($fh,$header['size']-8) : NULL);
if ($header['size'] >= 8) {
$o = class_exists($class)
? new $class($offset+$rp,$header['size']-8,$this->filename,$data,$passthru)
: new $unknown($offset+$rp,$header['size']-8,$this->filename,$header['atom'],$data);
$result->push($o);
$rp += $header['size']-8;
// Only need to seek if we didnt read all the data
if ((! $atom) && ($header['size'] > self::BLOCK_SIZE))
fseek($fh,$offset+$rp);
} else {
dd([get_class($this) => $data]);
}
// Work out if data from the last atom next to be passed onto the next one
if ($callback)
$passthru = $callback($o);
}
if (! $atom) {
fclose($fh);
unset($fh);
}
return $result;
}
/**
* Recursively look through our object hierarchy of atoms for a specific atom class
*
* @param string $subatom
* @param int|NULL $expect
* @param int $depth
* @return Collection|Atom|NULL
* @throws \Exception
*/
public function find_atoms(string $subatom,?int $expect=NULL,int $depth=100): Collection|Atom|NULL
{
if (! isset($this->atoms) || ($depth < 0))
return NULL;
$subatomo = $this->atoms->filter(fn($item)=>get_class($item)===$subatom);
$subatomo = $subatomo
->merge($this->atoms->map(fn($item)=>$item->find_atoms($subatom,NULL,$depth-1))
->filter(fn($item)=>$item ? $item->count() : NULL)
->flatten());
if (! $subatomo->count())
return $subatomo;
if ($expect && ($subatomo->count() !== $expect))
throw new \Exception(sprintf('! Expected %d subatoms of %s, but have %d',$expect,$subatom,$subatomo->count()));
return ($expect === 1) ? $subatomo->pop() : $subatomo;
}
}

View File

@ -6,10 +6,10 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.3", "php": "^8.3",
"ext-fileinfo": "*",
"ext-pdo": "*", "ext-pdo": "*",
"ext-imagick": "*", "ext-imagick": "*",
"ext-pgsql": "*", "ext-pgsql": "*",
"james-heinrich/getid3": "^1.9",
"laravel/framework": "^11.0", "laravel/framework": "^11.0",
"laravel/ui": "^4.5", "laravel/ui": "^4.5",
"leenooks/laravel": "^11.1", "leenooks/laravel": "^11.1",

View File

@ -1,8 +1,98 @@
<?php <?php
if (! function_exists('stringtrim')) { if (! function_exists('stringtrim')) {
function stringtrim(string $string,int $chrs=6) function stringtrim(string $string,int $chrs=6): string
{ {
return sprintf('%s...%s',substr($string,0,$chrs),substr($string,-1*$chrs)); return sprintf('%s...%s',substr($string,0,$chrs),substr($string,-1*$chrs));
} }
}
/**
* Dump out data into a hex dump
*/
if (! function_exists('hex_dump')) {
function hex_dump($data,$newline="\n",$width=16): string
{
$result = '';
$pad = '.'; # padding for non-visible characters
$to = $from = '';
for ($i=0; $i<=0xFF; $i++) {
$from .= chr($i);
$to .= ($i >= 0x20 && $i <= 0x7E) ? chr($i) : $pad;
}
$hex = str_split(bin2hex($data),$width*2);
$chars = str_split(strtr($data,$from,$to),$width);
$offset = 0;
foreach ($hex as $i => $line) {
$result .= sprintf('%08X: %-48s [%s]%s',
$offset,
substr_replace(implode(' ',str_split($line,2)),' ',8*3,0),
$chars[$i],
$newline);
$offset += $width;
}
return $result;
}
}
/**
* Determine if an int is valid for this environment
*/
if (! function_exists('is_valid_int')) {
function is_valid_int(mixed $num): bool
{
/*
// check if integers are 64-bit
static $hasINT64 = NULL;
if ($hasINT64 === NULL) {
$hasINT64 = is_int(pow(2, 31)); // 32-bit int are limited to (2^31)-1
if ((! $hasINT64) && (! defined('PHP_INT_MIN')))
define('PHP_INT_MIN', ~PHP_INT_MAX);
}
*/
// if integers are 64-bit - no other check required
return (($num <= PHP_INT_MAX) && ($num >= PHP_INT_MIN));
}
}
if (! function_exists('fixed_point_int_to_float')) {
function fixed_point_int_to_float(string $rawdata): float
{
if (! strlen($rawdata))
return 0;
$len = strlen($rawdata);
if ($len&($len-1) === 0)
throw new \Exception('Rawdata len must be a power of 2');
// Take the first part
$value = 0;
for ($i=0; $i<$len/2; $i++)
$value += ord($rawdata[$len/2+$i])*pow(256,(strlen($rawdata)-1-$i));
// Take the second part
$dec = 0;
for ($i=0; $i<$len/2; $i++)
$dec += ord($rawdata[$i])*pow(256,(strlen($rawdata)-1-$i));
return $value+$dec/pow(2,8*$len/2);
}
}
if (! function_exists('pascal_string')) {
function pascal_string(string $str): string
{
$len = ord(substr($str,0,1));
return substr($str,1,$len);
}
} }

View File

@ -0,0 +1,19 @@
<div id="map" class="w-100" style="height: 30em;"></div>
@section('page-scripts')
<script type="text/javascript" src="//unpkg.com/maplibre-gl/dist/maplibre-gl.js"></script>
<link type='text/css' href="//unpkg.com/maplibre-gl/dist/maplibre-gl.css" rel='stylesheet'>
<script type="text/javascript">
var map = new maplibregl.Map({
container: 'map', // container id
style: '/js/maplibre-style.json', // style URL
center: [{{ $o->gps_lon }}, {{ $o->gps_lat }}], // starting position [lng, lat]
zoom: 12 // starting zoom
});
let marker = new maplibregl.Marker()
.setLngLat([{{ $o->gps_lon }}, {{ $o->gps_lat }}])
.addTo(map);
</script>
@append

View File

@ -0,0 +1,3 @@
<a href="{{ url('p/view',$id) }}" target="{{ $id }}">
<img class="pb-3 w-100" src="{{ url('p/thumbnail',$id) }}">
</a>

View File

@ -1,3 +0,0 @@
<a href="{{ url('p/view',$id) }}" target="{{ $id }}">
<img class="p-3 w-100" src="{{ url('p/thumbnail',$id) }}">
</a>

View File

@ -8,7 +8,6 @@
Photo #{{ $po->id }} Photo #{{ $po->id }}
@endsection @endsection
@section('contentheader_description') @section('contentheader_description')
@endsection @endsection
@section('page_title') @section('page_title')
#{{ $po->id }} #{{ $po->id }}
@ -17,22 +16,10 @@
@section('main-content') @section('main-content')
<div class="row"> <div class="row">
<div class="col-3"> <div class="col-3">
<x-thumbnail :id="$po->id"/> <x-photo.thumbnail :id="$po->id"/>
<div class="pagination justify-content-center"> <div class="pagination justify-content-center">
<ul class="pagination"> <x-pagination.nav :o="$po" pre="p"/>
<li class="page-item @disabled(! $x=$po->previous())">
<a class="page-link" href="{{ $x ? url('p/info',$x->id) : '#' }}">&lt;&lt;</a>
</li>
<li class="page-item active">
<span class="page-link">{{ $po->id }}</span>
</li>
<li class="page-item @disabled(! $x=$po->next())">
<a class="page-link" href="{{ $x ? url('p/info',$x->id) : '#' }}">&gt;&gt;</a>
</li>
</ul>
</div> </div>
</div> </div>
@ -53,7 +40,7 @@
<dt>NEW Filename</dt><dd>{{ $po->file_name() }}<dd> <dt>NEW Filename</dt><dd>{{ $po->file_name() }}<dd>
@endif @endif
<dt>Size</dt><dd>{{ number_format($po->file_size,0) }}<dd> <dt>Size</dt><dd>{{ number_format($po->file_size) }}<dd>
<dt>Dimensions</dt><dd>{{ $po->dimensions }} @ {{ $po->orientation }}<dd> <dt>Dimensions</dt><dd>{{ $po->dimensions }} @ {{ $po->orientation }}<dd>
<hr> <hr>
<dt>Date Taken</dt><dd>{{ $po->created?->format('Y-m-d H:i:s') }}<dd> <dt>Date Taken</dt><dd>{{ $po->created?->format('Y-m-d H:i:s') }}<dd>
@ -66,7 +53,7 @@
@if(! $po->gps) @if(! $po->gps)
UNKNOWN UNKNOWN
@else @else
<div id="map" class="w-100" style="height: 30em;"></div> <x-map :o="$po"/>
@endif @endif
</dd> </dd>
@endif @endif
@ -112,26 +99,4 @@
</form> </form>
</div> </div>
</div> </div>
@endsection @endsection
@section('page-scripts')
@if($po->gps)
<script type="text/javascript" src="//unpkg.com/maplibre-gl/dist/maplibre-gl.js"></script>
<link type='text/css' href="//unpkg.com/maplibre-gl/dist/maplibre-gl.css" rel='stylesheet'>
<script type="text/javascript">
const key = 'hspQjLANaPwdHUrvUcsf';
var map = new maplibregl.Map({
container: 'map', // container id
style: '/js/maplibre-style.json', // style URL
center: [{{ $po->gps_lon }}, {{ $po->gps_lat }}], // starting position [lng, lat]
zoom: 12 // starting zoom
});
let marker = new maplibregl.Marker()
.setLngLat([{{ $po->gps_lon }}, {{ $po->gps_lat }}])
.addTo(map);
</script>
@endif
@append

View File

@ -25,7 +25,7 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<x-thumbnail :id="$o->id"/> <x-photo.thumbnail :id="$o->id"/>
</div> </div>
<div class="card-footer card-comments"> <div class="card-footer card-comments">

View File

@ -17,8 +17,8 @@ Route::get('/v/duplicates/{id?}',[VideoController::class,'duplicates'])
Route::view('/p/info/{po}','photo.view') Route::view('/p/info/{po}','photo.view')
->where('po','[0-9]+'); ->where('po','[0-9]+');
Route::get('/v/info/{o}',[VideoController::class,'info']) Route::view('/v/info/{vo}','video.view')
->where('o','[0-9]+'); ->where('vo','[0-9]+');
Route::get('/p/thumbnail/{o}',[PhotoController::class,'thumbnail']) Route::get('/p/thumbnail/{o}',[PhotoController::class,'thumbnail'])
->where('o','[0-9]+'); ->where('o','[0-9]+');
Route::get('/p/view/{o}',[PhotoController::class,'view']) Route::get('/p/view/{o}',[PhotoController::class,'view'])