#------------------------------------------------------------------------------ # File: QuickTimeStream.pl # # Description: Extract embedded information from QuickTime movie data # # Revisions: 2018-01-03 - P. Harvey Created # # References: 1) https://developer.apple.com/library/content/documentation/QuickTime/QTFF/QTFFChap3/qtff3.html#//apple_ref/doc/uid/TP40000939-CH205-SW130 # 2) http://sergei.nz/files/nvtk_mp42gpx.py # 3) https://forum.flitsservice.nl/dashcam-info/dod-ls460w-gps-data-uit-mov-bestand-lezen-t87926.html # 4) https://developers.google.com/streetview/publish/camm-spec # 5) https://sergei.nz/extracting-gps-data-from-viofo-a119-and-other-novatek-powered-cameras/ #------------------------------------------------------------------------------ package Image::ExifTool::QuickTime; use strict; sub Process_tx3g($$$); sub ProcessFreeGPS($$$); sub ProcessFreeGPS2($$$); # QuickTime data types that have ExifTool equivalents # (ref https://developer.apple.com/library/content/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW35) my %qtFmt = ( 0 => 'undef', 1 => 'string', # (UTF-8) # 2 - UTF-16 # 3 - shift-JIS # 4 - UTF-8 sort # 5 - UTF-16 sort # 13 - JPEG image # 14 - PNG image # 21 - signed integer (1,2,3 or 4 bytes) # 22 - unsigned integer (1,2,3 or 4 bytes) 23 => 'float', 24 => 'double', # 27 - BMP image # 28 - QuickTime atom 65 => 'int8s', 66 => 'int16s', 67 => 'int32s', 70 => 'float', # float[2] x,y 71 => 'float', # float[2] width,height 72 => 'float', # float[4] x,y,width,height 74 => 'int64s', 75 => 'int8u', 76 => 'int16u', 77 => 'int32u', 78 => 'int64u', 79 => 'float', # float[9] transform matrix 80 => 'float', # float[8] face coordinates ); # maximums for validating H,M,S,d,m,Y from "freeGPS " metadata my @dateMax = ( 24, 59, 59, 2200, 12, 31 ); # size of freeGPS block my $gpsBlockSize = 0x8000; # conversion factors my $knotsToKph = 1.852; # knots --> km/h my $mpsToKph = 3.6; # m/s --> km/h # handler types to process based on MetaFormat/OtherFormat my %processByMetaFormat = ( meta => 1, # ('CTMD' in CR3 images, 'priv' unknown in DJI video) data => 1, # ('RVMI') sbtl => 1, # (subtitle; 'tx3g' in Yuneec drone videos) ); # tags extracted from various QuickTime data streams %Image::ExifTool::QuickTime::Stream = ( GROUPS => { 2 => 'Location' }, NOTES => q{ Timed metadata extracted from QuickTime movie data and some AVI videos when the ExtractEmbedded option is used. }, VARS => { NO_ID => 1 }, GPSLatitude => { PrintConv => 'Image::ExifTool::GPS::ToDMS($self, $val, 1, "N")' }, GPSLongitude => { PrintConv => 'Image::ExifTool::GPS::ToDMS($self, $val, 1, "E")' }, GPSAltitude => { PrintConv => '(sprintf("%.4f", $val) + 0) . " m"' }, # round to 4 decimals GPSSpeed => { PrintConv => 'sprintf("%.4f", $val) + 0' }, # round to 4 decimals GPSSpeedRef => { PrintConv => { K => 'km/h', M => 'mph', N => 'knots' } }, GPSTrack => { PrintConv => 'sprintf("%.4f", $val) + 0' }, # round to 4 decimals GPSTrackRef => { PrintConv => { M => 'Magnetic North', T => 'True North' } }, GPSDateTime => { PrintConv => '$self->ConvertDateTime($val)', Groups => { 2 => 'Time' } }, GPSTimeStamp => { PrintConv => 'Image::ExifTool::GPS::PrintTimeStamp($val)', Groups => { 2 => 'Time' } }, GPSSatellites=> { }, GPSDOP => { Description => 'GPS Dilution Of Precision' }, CameraDateTime=>{ PrintConv => '$self->ConvertDateTime($val)', Groups => { 2 => 'Time' } }, Accelerometer=> { Notes => 'right/up/backward acceleration in units of g' }, RawGSensor => { # (same as GSensor, but offset by some unknown value) ValueConv => 'my @a=split " ",$val; $_/=1000 foreach @a; "@a"', }, Text => { Groups => { 2 => 'Other' } }, TimeCode => { Groups => { 2 => 'Video' } }, FrameNumber => { Groups => { 2 => 'Video' } }, SampleTime => { Groups => { 2 => 'Video' }, PrintConv => 'ConvertDuration($val)', Notes => 'sample decoding time' }, SampleDuration=>{ Groups => { 2 => 'Video' }, PrintConv => 'ConvertDuration($val)' }, UserLabel => { Groups => { 2 => 'Other' } }, # # timed metadata decoded based on MetaFormat (format of 'meta' or 'data' sample description) # [or HandlerType, or specific 'vide' type if specified] # mebx => { Name => 'mebx', SubDirectory => { TagTable => 'Image::ExifTool::QuickTime::Keys', ProcessProc => \&Process_mebx, }, }, gpmd => { Name => 'gpmd', SubDirectory => { TagTable => 'Image::ExifTool::GoPro::GPMF' }, }, fdsc => { Name => 'fdsc', Condition => '$$valPt =~ /^GPRO/', # (other types of "fdsc" samples aren't yet parsed: /^GP\x00/ and /^GP\x04/) SubDirectory => { TagTable => 'Image::ExifTool::GoPro::fdsc' }, }, rtmd => { Name => 'rtmd', SubDirectory => { TagTable => 'Image::ExifTool::Sony::rtmd' }, }, CTMD => { # (Canon Timed MetaData) Name => 'CTMD', SubDirectory => { TagTable => 'Image::ExifTool::Canon::CTMD' }, }, tx3g => { Name => 'tx3g', SubDirectory => { TagTable => 'Image::ExifTool::QuickTime::tx3g' }, }, RVMI => [{ # data "OtherFormat" written by unknown software Name => 'RVMI_gReV', Condition => '$$valPt =~ /^gReV/', # GPS data SubDirectory => { TagTable => 'Image::ExifTool::QuickTime::RVMI_gReV', ByteOrder => 'Little-endian', }, },{ Name => 'RVMI_sReV', Condition => '$$valPt =~ /^sReV/', # sensor data SubDirectory => { TagTable => 'Image::ExifTool::QuickTime::RVMI_sReV', ByteOrder => 'Little-endian', }, # (there is also "tReV" data that hasn't been decoded yet) }], camm => [{ Name => 'camm0', Condition => '$$valPt =~ /^\0\0\0\0/', SubDirectory => { TagTable => 'Image::ExifTool::QuickTime::camm0', ByteOrder => 'Little-Endian', }, },{ Name => 'camm1', Condition => '$$valPt =~ /^\0\0\x01\0/', SubDirectory => { TagTable => 'Image::ExifTool::QuickTime::camm1', ByteOrder => 'Little-Endian', }, },{ # (written by Insta360) - [HandlerType, not MetaFormat] Name => 'camm2', Condition => '$$valPt =~ /^\0\0\x02\0/', SubDirectory => { TagTable => 'Image::ExifTool::QuickTime::camm2', ByteOrder => 'Little-Endian', }, },{ Name => 'camm3', Condition => '$$valPt =~ /^\0\0\x03\0/', SubDirectory => { TagTable => 'Image::ExifTool::QuickTime::camm3', ByteOrder => 'Little-Endian', }, },{ Name => 'camm4', Condition => '$$valPt =~ /^\0\0\x04\0/', SubDirectory => { TagTable => 'Image::ExifTool::QuickTime::camm4', ByteOrder => 'Little-Endian', }, },{ Name => 'camm5', Condition => '$$valPt =~ /^\0\0\x05\0/', SubDirectory => { TagTable => 'Image::ExifTool::QuickTime::camm5', ByteOrder => 'Little-Endian', }, },{ Name => 'camm6', Condition => '$$valPt =~ /^\0\0\x06\0/', SubDirectory => { TagTable => 'Image::ExifTool::QuickTime::camm6', ByteOrder => 'Little-Endian', }, },{ Name => 'camm7', Condition => '$$valPt =~ /^\0\0\x07\0/', SubDirectory => { TagTable => 'Image::ExifTool::QuickTime::camm7', ByteOrder => 'Little-Endian', }, },], JPEG => { # (in CR3 images) - [vide HandlerType with JPEG in SampleDescription, not MetaFormat] Name => 'JpgFromRaw', Groups => { 2 => 'Preview' }, RawConv => '$self->ValidateImage(\$val,$tag)', }, ); # tags found in 'camm' type 0 timed metadata (ref 4) %Image::ExifTool::QuickTime::camm0 = ( PROCESS_PROC => \&Image::ExifTool::ProcessBinaryData, GROUPS => { 2 => 'Location' }, FIRST_ENTRY => 0, NOTES => q{ The camm0 through camm7 tables define tags extracted from the Google Street View Camera Motion Metadata of MP4 videos. See L for the specification. }, 4 => { Name => 'AngleAxis', Notes => 'angle axis orientation in radians in local coordinate system', Format => 'float[3]', }, ); # tags found in 'camm' type 1 timed metadata (ref 4) %Image::ExifTool::QuickTime::camm1 = ( PROCESS_PROC => \&Image::ExifTool::ProcessBinaryData, GROUPS => { 2 => 'Camera' }, FIRST_ENTRY => 0, 4 => { Name => 'PixelExposureTime', Format => 'int32s', ValueConv => '$val * 1e-9', PrintConv => 'sprintf("%.4g ms", $val * 1000)', }, 8 => { Name => 'RollingShutterSkewTime', Format => 'int32s', ValueConv => '$val * 1e-9', PrintConv => 'sprintf("%.4g ms", $val * 1000)', }, ); # tags found in 'camm' type 2 timed metadata (ref PH, Insta360Pro) %Image::ExifTool::QuickTime::camm2 = ( PROCESS_PROC => \&Image::ExifTool::ProcessBinaryData, GROUPS => { 2 => 'Location' }, FIRST_ENTRY => 0, 4 => { Name => 'AngularVelocity', Notes => 'gyro angular velocity about X, Y and Z axes in rad/s', Format => 'float[3]', }, ); # tags found in 'camm' type 3 timed metadata (ref PH, Insta360Pro) %Image::ExifTool::QuickTime::camm3 = ( PROCESS_PROC => \&Image::ExifTool::ProcessBinaryData, GROUPS => { 2 => 'Location' }, FIRST_ENTRY => 0, 4 => { Name => 'Acceleration', Notes => 'acceleration in the X, Y and Z directions in m/s^2', Format => 'float[3]', }, ); # tags found in 'camm' type 4 timed metadata (ref 4) %Image::ExifTool::QuickTime::camm4 = ( PROCESS_PROC => \&Image::ExifTool::ProcessBinaryData, GROUPS => { 2 => 'Location' }, FIRST_ENTRY => 0, 4 => { Name => 'Position', Notes => 'X, Y, Z position in local coordinate system', Format => 'float[3]', }, ); # tags found in 'camm' type 5 timed metadata (ref 4) %Image::ExifTool::QuickTime::camm5 = ( PROCESS_PROC => \&Image::ExifTool::ProcessBinaryData, GROUPS => { 2 => 'Location' }, FIRST_ENTRY => 0, 4 => { Name => 'GPSLatitude', Format => 'double', ValueConv => 'Image::ExifTool::GPS::ToDegrees($val, 1)', PrintConv => 'Image::ExifTool::GPS::ToDMS($self, $val, 1, "N")', }, 12 => { Name => 'GPSLongitude', Format => 'double', ValueConv => 'Image::ExifTool::GPS::ToDegrees($val, 1)', PrintConv => 'Image::ExifTool::GPS::ToDMS($self, $val, 1, "E")', }, 20 => { Name => 'GPSAltitude', Format => 'double', PrintConv => '$_ = sprintf("%.6f", $val); s/\.?0+$//; "$_ m"', }, ); # tags found in 'camm' type 6 timed metadata (ref PH/4, Insta360) %Image::ExifTool::QuickTime::camm6 = ( PROCESS_PROC => \&Image::ExifTool::ProcessBinaryData, GROUPS => { 2 => 'Location' }, FIRST_ENTRY => 0, 0x04 => { Name => 'GPSDateTime', Groups => { 2 => 'Time' }, Format => 'double', ValueConv => q{ my $str = ConvertUnixTime($val); my $frac = $val - int($val); if ($frac != 0) { $frac = sprintf('%.6f', $frac); $frac =~ s/^0//; $frac =~ s/0+$//; $str .= $frac; } return $str . 'Z'; }, PrintConv => '$self->ConvertDateTime($val)', }, 0x0c => { Name => 'GPSMeasureMode', Format => 'int32u', PrintConv => { 0 => 'No Measurement', 2 => '2-Dimensional Measurement', 3 => '3-Dimensional Measurement', }, }, 0x10 => { Name => 'GPSLatitude', Format => 'double', ValueConv => 'Image::ExifTool::GPS::ToDegrees($val, 1)', PrintConv => 'Image::ExifTool::GPS::ToDMS($self, $val, 1, "N")', }, 0x18 => { Name => 'GPSLongitude', Format => 'double', ValueConv => 'Image::ExifTool::GPS::ToDegrees($val, 1)', PrintConv => 'Image::ExifTool::GPS::ToDMS($self, $val, 1, "E")', }, 0x20 => { Name => 'GPSAltitude', Format => 'float', PrintConv => '$_ = sprintf("%.3f", $val); s/\.?0+$//; "$_ m"', }, 0x24 => { Name => 'GPSHorizontalAccuracy', Format => 'float', Notes => 'metres' }, 0x28 => { Name => 'GPSVerticalAccuracy', Format => 'float' }, 0x2c => { Name => 'GPSVelocityEast', Format => 'float', Notes => 'm/s' }, 0x30 => { Name => 'GPSVelocityNorth', Format => 'float' }, 0x34 => { Name => 'GPSVelocityUp', Format => 'float' }, 0x38 => { Name => 'GPSSpeedAccuracy', Format => 'float' }, ); # tags found in 'camm' type 7 timed metadata (ref 4) %Image::ExifTool::QuickTime::camm7 = ( PROCESS_PROC => \&Image::ExifTool::ProcessBinaryData, GROUPS => { 2 => 'Location' }, FIRST_ENTRY => 0, 4 => { Name => 'MagneticField', Format => 'float[3]', Notes => 'microtesla', }, ); # tags found in 'RVMI' 'gReV' timed metadata (ref PH) %Image::ExifTool::QuickTime::RVMI_gReV = ( PROCESS_PROC => \&Image::ExifTool::ProcessBinaryData, GROUPS => { 2 => 'Location' }, FIRST_ENTRY => 0, NOTES => q{ GPS information extracted from the RVMI box of MOV videos. }, 4 => { Name => 'GPSLatitude', Format => 'int32s', ValueConv => 'Image::ExifTool::GPS::ToDegrees($val/1e6, 1)', PrintConv => 'Image::ExifTool::GPS::ToDMS($self, $val, 1, "N")', }, 8 => { Name => 'GPSLongitude', Format => 'int32s', ValueConv => 'Image::ExifTool::GPS::ToDegrees($val/1e6, 1)', PrintConv => 'Image::ExifTool::GPS::ToDMS($self, $val, 1, "E")', }, # 12 - int32s: space for altitude? (always zero in my sample) 16 => { Name => 'GPSSpeed', # km/h Format => 'int16s', ValueConv => '$val / 10', }, 18 => { Name => 'GPSTrack', Format => 'int16u', ValueConv => '$val * 2', }, ); # tags found in 'RVMI' 'sReV' timed metadata (ref PH) %Image::ExifTool::QuickTime::RVMI_sReV = ( PROCESS_PROC => \&Image::ExifTool::ProcessBinaryData, GROUPS => { 2 => 'Location' }, FIRST_ENTRY => 0, NOTES => q{ G-sensor information extracted from the RVMI box of MOV videos. }, 4 => { Name => 'GSensor', Format => 'int16s[3]', # X Y Z ValueConv => 'my @a=split " ",$val; $_/=1000 foreach @a; "@a"', }, ); # tags found in 'tx3g' sbtl timed metadata (ref PH) %Image::ExifTool::QuickTime::tx3g = ( PROCESS_PROC => \&Process_tx3g, GROUPS => { 2 => 'Location' }, FIRST_ENTRY => 0, NOTES => 'Tags extracted from the tx3g sbtl timed metadata of Yuneec drones.', Lat => { Name => 'GPSLatitude', PrintConv => 'Image::ExifTool::GPS::ToDMS($self, $val, 1, "N")', }, Lon => { Name => 'GPSLongitude', PrintConv => 'Image::ExifTool::GPS::ToDMS($self, $val, 1, "E")', }, Alt => { Name => 'GPSAltitude', ValueConv => '$val =~ s/\s*m$//; $val', # remove " m" PrintConv => '"$val m"', # add it back again }, Yaw => 'Yaw', Pitch => 'Pitch', Roll => 'Roll', GimYaw => 'GimbalYaw', GimPitch => 'GimbalPitch', GimRoll => 'GimbalRoll', ); #------------------------------------------------------------------------------ # Save information from keys in OtherSampleDesc directory for processing timed metadata # Inputs: 0) ExifTool object ref, 1) dirInfo ref, 2) tag table ref # Returns: 1 on success # (ref "Timed Metadata Media" here: # https://developer.apple.com/library/content/documentation/QuickTime/QTFF/QTFFChap3/qtff3.html) sub SaveMetaKeys($$$) { local $_; my ($et, $dirInfo, $tagTbl) = @_; my $dataPt = $$dirInfo{DataPt}; my $dirLen = length $$dataPt; return 0 unless $dirLen > 8; my $pos = 0; my $verbose = $$et{OPTIONS}{Verbose}; my $oldIndent = $$et{INDENT}; my $ee = $$et{ee}; $ee or $ee = $$et{ee} = { }; $verbose and $et->VerboseDir($$dirInfo{DirName}, undef, $dirLen); # loop through metadata key table while ($pos + 8 < $dirLen) { my $size = Get32u($dataPt, $pos); my $id = substr($$dataPt, $pos+4, 4); my $end = $pos + $size; $end = $dirLen if $end > $dirLen; $pos += 8; my ($tagID, $format, $pid); if ($verbose) { $pid = PrintableTagID($id,1); $et->VPrint(0, "$oldIndent+ [Metdata Key entry, Local ID=$pid, $size bytes]\n"); $$et{INDENT} .= '| '; } while ($pos + 4 < $end) { my $len = unpack("x${pos}N", $$dataPt); last if $len < 8 or $pos + $len > $end; my $tag = substr($$dataPt, $pos + 4, 4); $pos += 8; $len -= 8; my $val = substr($$dataPt, $pos, $len); $pos += $len; my $str; if ($tag eq 'keyd') { ($tagID = $val) =~ s/^(mdta|fiel)com\.apple\.quicktime\.//; $tagID = "Tag_$val" unless $tagID; ($str = $val) =~ s/(.{4})/$1 / if $verbose; } elsif ($tag eq 'dtyp') { next if length $val < 4; if (length $val >= 4) { my $ns = unpack('N', $val); if ($ns == 0) { length $val >= 8 or $et->Warn('Short dtyp data'), next; $str = unpack('x4N',$val); $format = $qtFmt{$str} || 'undef'; } elsif ($ns == 1) { $str = substr($val, 4); $format = 'undef'; } else { $format = 'undef'; } $str .= " ($format)" if $verbose and defined $str; } } if ($verbose > 1) { if (defined $str) { $str =~ tr/\x00-\x1f\x7f-\xff/./; $str = " = $str"; } else { $str = ''; } $et->VPrint(1, $$et{INDENT}."- Tag '".PrintableTagID($tag)."' ($len bytes)$str\n"); $et->VerboseDump(\$val); } } if (defined $tagID and defined $format) { if ($verbose) { my $t2 = PrintableTagID($tagID); $et->VPrint(0, "$$et{INDENT}Added Local ID $pid = $t2 ($format)\n"); } $$ee{'keys'}{$id} = { TagID => $tagID, Format => $format }; } $$et{INDENT} = $oldIndent; } return 1; } #------------------------------------------------------------------------------ # We found some tags for this sample, so set document number and save timing information # Inputs: 0) ExifTool ref, 1) tag table ref, 2) sample time, 3) sample duration sub FoundSomething($$$$) { my ($et, $tagTbl, $time, $dur) = @_; $$et{DOC_NUM} = ++$$et{DOC_COUNT}; $et->HandleTag($tagTbl, SampleTime => $time) if defined $time; $et->HandleTag($tagTbl, SampleDuration => $dur) if defined $dur; } #------------------------------------------------------------------------------ # Exract embedded metadata from media samples # Inputs: 0) ExifTool ref # Notes: Also accesses ExifTool RAF*, SET_GROUP1, HandlerType, MetaFormat, # ee*, and avcC elements (* = must exist) sub ProcessSamples($) { my $et = shift; my ($raf, $ee) = @$et{qw(RAF ee)}; my ($i, $buff, $pos, $hdrLen, $hdrFmt, @time, @dur, $oldIndent); return unless $ee; delete $$et{ee}; # use only once # only process specific types of video streams my $type = $$et{HandlerType} || ''; if ($type eq 'vide') { if ($$ee{avcC}) { $type = 'avcC' } elsif ($$ee{JPEG}) { $type = 'JPEG' } else { return } } my ($start, $size) = @$ee{qw(start size)}; # # determine sample start offsets from chunk offsets (stco) and sample-to-chunk table (stsc), # and sample time/duration from time-to-sample (stts) # unless ($start and $size) { return unless $size; my ($stco, $stsc, $stts) = @$ee{qw(stco stsc stts)}; return unless $stco and $stsc and @$stsc; $start = [ ]; my ($nextChunk, $iChunk) = (0, 1); my ($chunkStart, $startChunk, $samplesPerChunk, $descIdx, $timeCount, $timeDelta, $time); if ($stts and @$stts > 1) { $time = 0; $timeCount = shift @$stts; $timeDelta = shift @$stts; } my $ts = $$et{MediaTS} || 1; foreach $chunkStart (@$stco) { if ($iChunk >= $nextChunk and @$stsc) { ($startChunk, $samplesPerChunk, $descIdx) = @{shift @$stsc}; $nextChunk = $$stsc[0][0] if @$stsc; } @$size < @$start + $samplesPerChunk and $et->WarnOnce('Sample size error'), return; my $sampleStart = $chunkStart; for ($i=0; ; ) { push @$start, $sampleStart; if (defined $time) { until ($timeCount) { if (@$stts < 2) { undef $time; last; } $timeCount = shift @$stts; $timeDelta = shift @$stts; } push @time, $time / $ts; push @dur, $timeDelta / $ts; $time += $timeDelta; --$timeCount; } # (eventually should use the description indices: $descIdx) last if ++$i >= $samplesPerChunk; $sampleStart += $$size[$#$start]; } ++$iChunk; } @$start == @$size or $et->WarnOnce('Incorrect sample start/size count'), return; } # # extract and parse the sample data # my $tagTbl = GetTagTable('Image::ExifTool::QuickTime::Stream'); my $verbose = $et->Options('Verbose'); my $metaFormat = $$et{MetaFormat} || ''; my $tell = $raf->Tell(); if ($verbose) { $et->VPrint(0, "---- Extract Embedded ----\n"); $oldIndent = $$et{INDENT}; $$et{INDENT} = ''; } # get required information from avcC box if parsing video data if ($type eq 'avcC') { $hdrLen = (Get8u(\$$ee{avcC}, 4) & 0x03) + 1; $hdrFmt = ($hdrLen == 4 ? 'N' : $hdrLen == 2 ? 'n' : 'C'); require Image::ExifTool::H264; } # loop through all samples for ($i=0; $i<@$start and $i<@$size; ++$i) { # read the sample data my $size = $$size[$i]; next unless $raf->Seek($$start[$i], 0) and $raf->Read($buff, $size) == $size; if ($type eq 'avcC') { next if length($buff) <= $hdrLen; # scan through all NAL units and send them to ParseH264Video() for ($pos=0; ; ) { my $len = unpack("x$pos$hdrFmt", $buff); last if $pos + $hdrLen + $len > length($buff); my $tmp = "\0\0\0\x01" . substr($buff, $pos+$hdrLen, $len); Image::ExifTool::H264::ParseH264Video($et, \$tmp); $pos += $hdrLen + $len; last if $pos + $hdrLen >= length($buff); } next; } if ($verbose > 1) { my $hdr = $$et{SET_GROUP1} ? "$$et{SET_GROUP1} Type='${type}' Format='${metaFormat}'" : "Type='${type}'"; $et->VPrint(1, "${hdr}, Sample ".($i+1).' of '.scalar(@$start)." ($size bytes)\n"); $et->VerboseDump(\$buff, Addr => $$start[$i]); } if ($type eq 'text') { FoundSomething($et, $tagTbl, $time[$i], $dur[$i]); unless ($buff =~ /^\$BEGIN/) { # remove ending "encd" box if it exists $buff =~ s/\0\0\0\x0cencd\0\0\x01\0$// and $size -= 12; # cameras such as the CanonPowerShotN100 store ASCII time codes with a # leading 2-byte integer giving the length of the string # (and chapter names start with a 2-byte integer too) if ($size >= 2 and unpack('n',$buff) == $size - 2) { next if $size == 2; $buff = substr($buff,2); } my $val; # check for encrypted GPS text as written by E-PRANCE B47FS camera if ($buff =~ /^\0/ and $buff =~ /\x0a$/ and length($buff) > 5) { # decode simple ASCII difference cipher, # based on known value of 4th-last char = '*' my $dif = ord('*') - ord(substr($buff, -4, 1)); my $tmp = pack 'C*',map { $_=($_+$dif)&0xff } unpack 'C*',substr $buff,1,-1; if ($verbose > 2) { $et->VPrint(0, "[decrypted text]\n"); $et->VerboseDump(\$tmp); } if ($tmp =~ /^(.*?)(\$[A-Z]{2}RMC.*)/s) { ($val, $buff) = ($1, $2); $val =~ tr/\t/ /; $et->HandleTag($tagTbl, RawGSensor => $val) if length $val; } } unless (defined $val) { $et->HandleTag($tagTbl, Text => $buff); # just store any other text next; } } while ($buff =~ /\$(\w+)([^\$]*)/g) { my ($tag, $dat) = ($1, $2); if ($tag =~ /^[A-Z]{2}RMC$/ and $dat =~ /^,(\d{2})(\d{2})(\d+(\.\d*)?),A?,(\d*?)(\d{1,2}\.\d+),([NS]),(\d*?)(\d{1,2}\.\d+),([EW]),(\d*\.?\d*),(\d*\.?\d*),(\d{2})(\d{2})(\d+)/) { $et->HandleTag($tagTbl, GPSLatitude => (($5 || 0) + $6/60) * ($7 eq 'N' ? 1 : -1)); $et->HandleTag($tagTbl, GPSLongitude => (($8 || 0) + $9/60) * ($10 eq 'E' ? 1 : -1)); if (length $11) { $et->HandleTag($tagTbl, GPSSpeed => $11 * $knotsToKph); $et->HandleTag($tagTbl, GPSSpeedRef => 'K'); } if (length $12) { $et->HandleTag($tagTbl, GPSTrack => $11); $et->HandleTag($tagTbl, GPSTrackRef => 'T'); } my $year = $15 + ($15 >= 70 ? 1900 : 2000); my $str = sprintf('%.4d:%.2d:%.2d %.2d:%.2d:%.2dZ', $year, $14, $13, $1, $2, $3); $et->HandleTag($tagTbl, GPSDateTime => $str); } elsif ($tag eq 'BEGINGSENSOR' and $dat =~ /^:([-+]\d+\.\d+):([-+]\d+\.\d+):([-+]\d+\.\d+)/) { $et->HandleTag($tagTbl, Accelerometer => "$1 $2 $3"); } elsif ($tag eq 'TIME' and $dat =~ /^:(\d+)/) { $et->HandleTag($tagTbl, TimeCode => $1 / ($$et{MediaTS} || 1)); } elsif ($tag eq 'BEGIN') { $et->HandleTag($tagTbl, Text => $dat) if length $dat; } elsif ($tag ne 'END') { $et->HandleTag($tagTbl, Text => "\$$tag$dat"); } } } elsif ($processByMetaFormat{$type}) { if ($$tagTbl{$metaFormat}) { my $tagInfo = $et->GetTagInfo($tagTbl, $metaFormat, \$buff); if ($tagInfo) { FoundSomething($et, $tagTbl, $time[$i], $dur[$i]); $$et{ee} = $ee; # need ee information for 'keys' $et->HandleTag($tagTbl, $metaFormat, undef, DataPt => \$buff, Base => $$start[$i], TagInfo => $tagInfo, ); delete $$et{ee}; } } elsif ($verbose) { $et->VPrint(0, "Unknown meta format ($metaFormat)"); } } elsif ($type eq 'gps ') { # (ie. GPSDataList tag) if ($buff =~ /^....freeGPS /s) { # decode "freeGPS " data (Novatek) ProcessFreeGPS($et, { DataPt => \$buff, SampleTime => $time[$i], SampleDuration => $dur[$i], }, $tagTbl) ; } } elsif ($$tagTbl{$type}) { my $tagInfo = $et->GetTagInfo($tagTbl, $type, \$buff); if ($tagInfo) { FoundSomething($et, $tagTbl, $time[$i], $dur[$i]); $et->HandleTag($tagTbl, $type, undef, DataPt => \$buff, Base => $$start[$i], TagInfo => $tagInfo, ); } } } if ($verbose) { $$et{INDENT} = $oldIndent; $et->VPrint(0, "--------------------------\n"); } # clean up $raf->Seek($tell, 0); # restore original file position $$et{DOC_NUM} = 0; $$et{HandlerType} = $$et{HanderDesc} = ''; } #------------------------------------------------------------------------------ # Process "freeGPS " data blocks referenced by a 'gps ' (GPSDataList) atom # Inputs: 0) ExifTool ref, 1) dirInfo ref {DataPt,SampleTime,SampleDuration}, 2) tagTable ref # Returns: 1 on success (or 0 on unrecognized or "measurement-void" GPS data) # Notes: # - also see ProcessFreeGPS2() below for processing of other types of freeGPS blocks sub ProcessFreeGPS($$$) { my ($et, $dirInfo, $tagTbl) = @_; my $dataPt = $$dirInfo{DataPt}; my ($yr, $mon, $day, $hr, $min, $sec, $stat, $lbl); my ($lat, $latRef, $lon, $lonRef, $spd, $trk, $alt, @acc, @xtra); return 0 unless length $$dataPt >= 92; if (substr($$dataPt,12,1) eq "\x05") { # decode encrypted ASCII-based GPS (DashCam Azdome GS63H, ref 5) # header looks like this in my sample: # 0000: 00 00 80 00 66 72 65 65 47 50 53 20 05 01 00 00 [....freeGPS ....] # 0010: 01 03 aa aa f2 e1 f0 ee 54 54 98 9a 9b 92 9a 93 [........TT......] # 0020: 98 9e 98 98 9e 93 98 92 a6 9f 9f 9c 9d ed fa 8a [................] my $n = length($$dataPt) - 18; $n = 0x101 if $n > 0x101; my $buf2 = pack 'C*', map { $_ ^ 0xaa } unpack 'C*', substr($$dataPt,18,$n); if ($et->Options('Verbose') > 1) { $et->VPrint(1, '[decrypted freeGPS data]'); $et->VerboseDump(\$buf2); } # (extract longitude as 9 digits, not 8, ref PH) return 0 unless $buf2 =~ /^.{8}(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2}).(.{15})([NS])(\d{8})([EW])(\d{9})(\d{8})/s; ($yr,$mon,$day,$hr,$min,$sec,$lbl,$latRef,$lat,$lonRef,$lon,$spd) = ($1,$2,$3,$4,$5,$6,$7,$8,$9/1e4,$10,$11/1e4,$12); $spd += 0; # remove leading 0's $lbl =~ s/\0.*//s; $lbl =~ s/\s+$//; # truncate at null and remove trailing spaces push @xtra, UserLabel => $lbl if length $lbl; # extract accelerometer data (ref PH) @acc = ($1/100,$2/100,$3/100) if $buf2 =~ /^.{173}([-+]\d{3})([-+]\d{3})([-+]\d{3})/s; } elsif ($$dataPt =~ /^.{52}(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/) { # decode NMEA-format GPS data (NextBase 512GW dashcam, ref PH) # header looks like this in my sample: # 0000: 00 00 80 00 66 72 65 65 47 50 53 20 40 01 00 00 [....freeGPS @...] # 0010: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 [................] # 0020: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 [................] push @xtra, CameraDateTime => "$1:$2:$3 $4:$5:$6"; if ($$dataPt =~ /\$[A-Z]{2}RMC,(\d{2})(\d{2})(\d+(\.\d*)?),A?,(\d+\.\d+),([NS]),(\d+\.\d+),([EW]),(\d*\.?\d*),(\d*\.?\d*),(\d{2})(\d{2})(\d+)/s) { ($lat,$latRef,$lon,$lonRef) = ($5,$6,$7,$8); $yr = $13 + ($13 >= 70 ? 1900 : 2000); ($mon,$day,$hr,$min,$sec) = ($12,$11,$1,$2,$3); $spd = $9 * $knotsToKph if length $9; $trk = $10 if length $10; } if ($$dataPt =~ /\$[A-Z]{2}GGA,(\d{2})(\d{2})(\d+(\.\d*)?),(\d+\.\d+),([NS]),(\d+\.\d+),([EW]),[1-6]?,(\d+)?,(\.\d+|\d+\.?\d*)?,(-?\d+\.?\d*)?,M?/s) { ($hr,$min,$sec,$lat,$latRef,$lon,$lonRef) = ($1,$2,$3,$5,$6,$7,$8) unless defined $yr; $alt = $11; unshift @xtra, GPSSatellites => $9; unshift @xtra, GPSDOP => $10; } if (defined $lat) { # extract accelerometer readings if GPS was valid @acc = unpack('x68V3', $$dataPt); # change to signed integer and divide by 256 map { $_ = $_ - 4294967296 if $_ >= 2147483648; $_ /= 256 } @acc; } } else { # decode binary GPS format (Viofo A119S, ref 2) # header looks like this in my sample: # 0000: 00 00 80 00 66 72 65 65 47 50 53 20 4c 00 00 00 [....freeGPS L...] # 0010: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 [................] # 0020: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 [................] # (records are same structure as Type 3 Novatek GPS in ProcessFreeGPS2() below) ($hr,$min,$sec,$yr,$mon,$day,$stat,$latRef,$lonRef,$lat,$lon,$spd,$trk) = unpack('x48V6a1a1a1x1V4', $$dataPt); # ignore invalid fixes return 0 unless $stat eq 'A' and ($latRef eq 'N' or $latRef eq 'S') and ($lonRef eq 'E' or $lonRef eq 'W'); ($lat,$lon,$spd,$trk) = unpack 'f*', pack 'L*', $lat, $lon, $spd, $trk; $yr += 2000 if $yr < 2000; $spd *= $knotsToKph; # convert speed to km/h # ($trk is not confirmed; may be GPSImageDirection, ref PH) } # # save tag values extracted by above code # FoundSomething($et, $tagTbl, $$dirInfo{SampleTime}, $$dirInfo{SampleDuration}); # lat/long are in DDDmm.mmmm format my $deg = int($lat / 100); $lat = $deg + ($lat - $deg * 100) / 60; $deg = int($lon / 100); $lon = $deg + ($lon - $deg * 100) / 60; $sec = '0' . $sec unless $sec =~ /^\d{2}/; # pad integer part of seconds to 2 digits if (defined $yr) { my $time = sprintf('%.4d:%.2d:%.2d %.2d:%.2d:%sZ',$yr,$mon,$day,$hr,$min,$sec); $et->HandleTag($tagTbl, GPSDateTime => $time); } elsif (defined $hr) { my $time = sprintf('%.2d:%.2d:%.2dZ',$hr,$min,$sec); $et->HandleTag($tagTbl, GPSTimeStamp => $time); } $et->HandleTag($tagTbl, GPSLatitude => $lat * ($latRef eq 'S' ? -1 : 1)); $et->HandleTag($tagTbl, GPSLongitude => $lon * ($lonRef eq 'W' ? -1 : 1)); $et->HandleTag($tagTbl, GPSAltitude => $alt) if defined $alt; if (defined $spd) { $et->HandleTag($tagTbl, GPSSpeed => $spd); $et->HandleTag($tagTbl, GPSSpeedRef => 'K'); } if (defined $trk) { $et->HandleTag($tagTbl, GPSTrack => $trk); $et->HandleTag($tagTbl, GPSTrackRef => 'T'); } while (@xtra) { my $tag = shift @xtra; $et->HandleTag($tagTbl, $tag => shift @xtra); } $et->HandleTag($tagTbl, Accelerometer => \@acc) if @acc; return 1; } #------------------------------------------------------------------------------ # Process "freeGPS " data blocks _not_ referenced by a 'gps ' atom # Inputs: 0) ExifTool ref, 1) dirInfo ref {DataPt,DataPos}, 2) tagTable ref # Returns: 1 on success # Notes: # - also see ProcessFreeGPS() above # - on entry, the length of $$dataPt will be at least $gpsBlockSize bytes long sub ProcessFreeGPS2($$$) { my ($et, $dirInfo, $tagTbl) = @_; my $dataPt = $$dirInfo{DataPt}; my ($yr, $mon, $day, $hr, $min, $sec, $pos); my ($lat, $latRef, $lon, $lonRef, $spd, $trk, $alt, $ddd, $unk); if (substr($$dataPt,0x45,3) eq 'ATC') { # header looks like this: (sample 1) # 0000: 00 00 80 00 66 72 65 65 47 50 53 20 38 06 00 00 [....freeGPS 8...] # 0010: 49 51 53 32 30 31 33 30 33 30 36 42 00 00 00 00 [IQS20130306B....] # 0020: 4d 61 79 20 31 35 20 32 30 31 35 2c 20 31 39 3a [May 15 2015, 19:] # (sample 2) # 0000: 00 00 80 00 66 72 65 65 47 50 53 20 4c 06 00 00 [....freeGPS L...] # 0010: 32 30 31 33 30 33 31 38 2e 30 31 00 00 00 00 00 [20130318.01.....] # 0020: 4d 61 72 20 31 38 20 32 30 31 33 2c 20 31 34 3a [Mar 18 2013, 14:] my ($recPos, $lastRecPos, $foundNew); my $verbose = $et->Options('Verbose'); my $dataPos = $$dirInfo{DataPos}; my $then = $$et{FreeGPS2}{Then}; $then or $then = $$et{FreeGPS2}{Then} = [ (0) x 6 ]; # Loop through records in the ATC-type GPS block until we find the most recent. # If we have already found one, then we only need to check the first record # (in case the buffer wrapped around), and the record after the position of # the last record we found, because the others will be old. Odd, but this # is the way it is done... I have only seen one new 52-byte record in the # entire 32 kB block, but the entire device ring buffer (containing 30 # entries in my samples) is stored every time. The code below allows for # the possibility of missing blocks and multiple new records in a single # block, but I have never seen this. Note that there may be some earlier # GPS records at the end of the first block that we will miss decoding, but # these should (I believe) be before the start of the video ATCRec: for ($recPos = 0x30; $recPos + 52 < $gpsBlockSize; $recPos += 52) { my $a = substr($$dataPt, $recPos, 52); # isolate a single record # decrypt record my @a = unpack('C*', $a); my ($key1, $key2) = @a[0x14, 0x1c]; $a[$_] ^= $key1 foreach 0x00..0x14, 0x18..0x1b; $a[$_] ^= $key2 foreach 0x1c, 0x20..0x32; my $b = pack 'C*', @a; # unpack and validate date/time my @now = unpack 'x13C3x28vC2', $b; # (H-1,M,S,Y,m,d) $now[0] = ($now[0] + 1) & 0xff; # increment hour my $i; for ($i=0; $i<@dateMax; ++$i) { next if $now[$i] <= $dateMax[$i]; $et->WarnOnce('Invalid GPS date/time'); next ATCRec; # ignore this record } # look for next ATC record in temporal sequence foreach $i (3..5, 0..2) { if ($now[$i] < $$then[$i]) { last ATCRec if $foundNew; last; } next if $now[$i] == $$then[$i]; # we found a more recent record -- extract it and remember its location if ($verbose) { $et->VPrint(2, " + [encrypted GPS record]\n"); $et->VerboseDump(\$a, DataPos => $dataPos + $recPos); $et->VPrint(2, " + [decrypted GPS record]\n"); $et->VerboseDump(\$b); #my @v = unpack 'H8VVC4V!CA3V!CA3VvvV!vCCCCH4', $b; #$et->VPrint(2, " + [unpacked: @v]\n"); # values unpacked above (ref PH): # 0) 0x00 4 bytes - byte 0=1, 1=counts to 255, 2=record index, 3=0 (ref 3) # 1) 0x04 4 bytes - int32u: bits 0-4=day, 5-8=mon, 9-19=year (ref 3) # 2) 0x08 4 bytes - int32u: bits 0-5=sec, 6-11=min, 12-16=hour (ref 3) # 3) 0x0c 1 byte - seen values of 0,1,2 - GPS status maybe? # 4) 0x0d 1 byte - hour minus 1 # 5) 0x0e 1 byte - minute # 6) 0x0f 1 byte - second # 7) 0x10 4 bytes - int32s latitude * 1e7 # 8) 0x14 1 byte - always 0 (used for decryption) # 9) 0x15 3 bytes - always "ATC" # 10) 0x18 4 bytes - int32s longitude * 1e7 # 11) 0x1c 1 byte - always 0 (used for decryption) # 12) 0x1d 3 bytes - always "001" # 13) 0x20 4 bytes - int32s speed * 100 (m/s) # 14) 0x24 2 bytes - int16u heading * 100 (-180 to 180 deg) # 15) 0x26 2 bytes - always zero # 16) 0x28 4 bytes - int32s altitude * 1000 (ref 3) # 17) 0x2c 2 bytes - int16u year # 18) 0x2e 1 byte - month # 19) 0x2f 1 byte - day # 20) 0x30 1 byte - unknown # 21) 0x31 1 byte - always zero # 22) 0x32 2 bytes - checksum ? } @$then = @now; $$et{DOC_NUM} = ++$$et{DOC_COUNT}; $trk = Get16s(\$b, 0x24) / 100; $trk += 360 if $trk < 0; my $time = sprintf('%.4d:%.2d:%.2d %.2d:%.2d:%.2dZ', @now[3..5, 0..2]); $et->HandleTag($tagTbl, GPSDateTime => $time); $et->HandleTag($tagTbl, GPSLatitude => Get32s(\$b, 0x10) / 1e7); $et->HandleTag($tagTbl, GPSLongitude => Get32s(\$b, 0x18) / 1e7); $et->HandleTag($tagTbl, GPSSpeed => Get32s(\$b, 0x20) / 100 * $mpsToKph); $et->HandleTag($tagTbl, GPSSpeedRef => 'K'); $et->HandleTag($tagTbl, GPSTrack => $trk); $et->HandleTag($tagTbl, GPSTrackRef => 'T'); $et->HandleTag($tagTbl, GPSAltitude => Get32s(\$b, 0x28) / 1000); $lastRecPos = $recPos; $foundNew = 1; # don't skip to location of previous recent record in ring buffer # since we found a more recent record here delete $$et{FreeGPS2}{RecentRecPos}; last; } # skip older records my $recentRecPos = $$et{FreeGPS2}{RecentRecPos}; $recPos = $recentRecPos if $recentRecPos and $recPos < $recentRecPos; } # save position of most recent record (needed when parsing the next freeGPS block) $$et{FreeGPS2}{RecentRecPos} = $lastRecPos; return 1; } elsif ($$dataPt =~ /^.{60}A\0.{10}([NS])\0.{14}([EW])\0/s) { # header looks like this in my sample: # 0000: 00 00 80 00 66 72 65 65 47 50 53 20 08 01 00 00 [....freeGPS ....] # 0010: 32 30 31 33 30 38 31 35 2e 30 31 00 00 00 00 00 [20130815.01.....] # 0020: 4a 75 6e 20 31 30 20 32 30 31 37 2c 20 31 34 3a [Jun 10 2017, 14:] # Type 2 (ref PH): # 0x30 - int32u hour # 0x34 - int32u minute # 0x38 - int32u second # 0x3c - int32u GPS status ('A' or 'V') # 0x40 - double latitude (DDMM.MMMMMM) # 0x48 - int32u latitude ref ('N' or 'S') # 0x50 - double longitude (DDMM.MMMMMM) # 0x58 - int32u longitude ref ('E' or 'W') # 0x60 - double speed (knots) # 0x68 - double heading (deg) # 0x70 - int32u year - 2000 # 0x74 - int32u month # 0x78 - int32u day ($latRef, $lonRef) = ($1, $2); ($hr,$min,$sec,$yr,$mon,$day) = unpack('x48V3x52V3', $$dataPt); $lat = GetDouble($dataPt, 0x40); $lon = GetDouble($dataPt, 0x50); $spd = GetDouble($dataPt, 0x60) * $knotsToKph; $trk = GetDouble($dataPt, 0x68); } elsif ($$dataPt =~ /^.{72}A([NS])([EW])/s) { # Type 3 (Novatek GPS, ref 2): (in case it wasn't decoded via 'gps ' atom) # 0x30 - int32u hour # 0x34 - int32u minute # 0x38 - int32u second # 0x3c - int32u year - 2000 # 0x40 - int32u month # 0x44 - int32u day # 0x48 - int8u GPS status ('A' or 'V') # 0x49 - int8u latitude ref ('N' or 'S') # 0x4a - int8u longitude ref ('E' or 'W') # 0x4b - 0 # 0x4c - float latitude (DDMM.MMMMMM) # 0x50 - float longitude (DDMM.MMMMMM) # 0x54 - float speed (knots) # 0x58 - float heading (deg) # Type 3b, same as above for 0x30-0x4a (ref PH) # 0x4c - int32s latitude (decimal degrees * 1e7) # 0x50 - int32s longitude (decimal degrees * 1e7) # 0x54 - int32s speed (m/s * 100) # 0x58 - float altitude (m * 1000, NC) ($latRef, $lonRef) = ($1, $2); ($hr,$min,$sec,$yr,$mon,$day) = unpack('x48V6', $$dataPt); if (substr($$dataPt, 16, 3) eq 'IQS') { # Type 3b (ref PH) # header looks like this in my sample: # 0000: 00 00 80 00 66 72 65 65 47 50 53 20 4c 00 00 00 [....freeGPS L...] # 0010: 49 51 53 5f 41 37 5f 32 30 31 35 30 34 31 37 00 [IQS_A7_20150417.] # 0020: 4d 61 72 20 32 39 20 32 30 31 37 2c 20 31 36 3a [Mar 29 2017, 16:] $ddd = 1; $lat = abs Get32s($dataPt, 0x4c) / 1e7; $lon = abs Get32s($dataPt, 0x50) / 1e7; $spd = Get32s($dataPt, 0x54) / 100 * $mpsToKph; $alt = GetFloat($dataPt, 0x58) / 1000; # (NC) } else { # Type 3 (ref 2) # (no sample with this format) $lat = GetFloat($dataPt, 0x4c); $lon = GetFloat($dataPt, 0x50); $spd = GetFloat($dataPt, 0x54) * $knotsToKph; $trk = GetFloat($dataPt, 0x58); } } else { # (look for binary GPS as stored by NextBase 512G, ref PH) # header looks like this in my sample: # 0000: 00 00 80 00 66 72 65 65 47 50 53 20 78 01 00 00 [....freeGPS x...] # 0010: 78 2e 78 78 00 00 00 00 00 00 00 00 00 00 00 00 [x.xx............] # 0020: 30 30 30 30 30 00 00 00 00 00 00 00 00 00 00 00 [00000...........] # followed by a number of 32-byte records in this format (big endian!): # 0x30 - int16u unknown (seen: 0x24 0x53 = "$S") # 0x32 - int16u speed (m/s * 100) # 0x34 - int16s heading (deg * 100) (or GPSImgDirection?) # 0x36 - int16u year # 0x38 - int8u month # 0x39 - int8u day # 0x3a - int8u hour # 0x3b - int8u min # 0x3c - int16u sec * 10 # 0x3e - int8u unknown (seen: 2) # 0x3f - int32s latitude (decimal degrees * 1e7) # 0x43 - int32s longitude (decimal degrees * 1e7) # 0x47 - int8u unknown (seen: 16) # 0x48-0x4f - all zero for ($pos=0x32; ; ) { ($spd,$trk,$yr,$mon,$day,$hr,$min,$sec,$unk,$lat,$lon) = unpack "x${pos}nnnCCCCnCNN", $$dataPt; # validate record using date/time last if $yr < 2000 or $yr > 2200 or $mon < 1 or $mon > 12 or $day < 1 or $day > 31 or $hr > 59 or $min > 59 or $sec > 600; # change lat/lon to signed integer and divide by 1e7 map { $_ = $_ - 4294967296 if $_ >= 2147483648; $_ /= 1e7 } $lat, $lon; $trk -= 0x10000 if $trk >= 0x8000; # make it signed $trk /= 100; $trk += 360 if $trk < 0; my $time = sprintf("%.4d:%.2d:%.2d %.2d:%.2d:%04.1fZ", $yr, $mon, $day, $hr, $min, $sec/10); $$et{DOC_NUM} = ++$$et{DOC_COUNT}; $et->HandleTag($tagTbl, GPSDateTime => $time); $et->HandleTag($tagTbl, GPSLatitude => $lat); $et->HandleTag($tagTbl, GPSLongitude => $lon); $et->HandleTag($tagTbl, GPSSpeed => $spd / 100 * $mpsToKph); $et->HandleTag($tagTbl, GPSSpeedRef => 'K'); $et->HandleTag($tagTbl, GPSTrack => $trk); $et->HandleTag($tagTbl, GPSTrackRef => 'T'); last if $pos += 0x20 > length($$dataPt) - 0x1e; } return $$et{DOC_NUM} ? 1 : 0; # return 0 if nothing extracted } # # save tag values extracted by above code # return 0 if $mon < 1 or $mon > 12; # quick sanity check $$et{DOC_NUM} = ++$$et{DOC_COUNT}; $yr += 2000 if $yr < 2000; my $time = sprintf('%.4d:%.2d:%.2d %.2d:%.2d:%.2dZ', $yr, $mon, $day, $hr, $min, $sec); # convert from DDMM.MMMMMM to DD.DDDDDD format if necessary unless ($ddd) { my $deg = int($lat / 100); $lat = $deg + ($lat - $deg * 100) / 60; $deg = int($lon / 100); $lon = $deg + ($lon - $deg * 100) / 60; } $et->HandleTag($tagTbl, GPSDateTime => $time); $et->HandleTag($tagTbl, GPSLatitude => $lat * ($latRef eq 'S' ? -1 : 1)); $et->HandleTag($tagTbl, GPSLongitude => $lon * ($lonRef eq 'W' ? -1 : 1)); $et->HandleTag($tagTbl, GPSSpeed => $spd); # (now in km/h) $et->HandleTag($tagTbl, GPSSpeedRef => 'K'); if (defined $trk) { $et->HandleTag($tagTbl, GPSTrack => $trk); $et->HandleTag($tagTbl, GPSTrackRef => 'T'); } if (defined $alt) { $et->HandleTag($tagTbl, GPSAltitude => $alt); } return 1; } #------------------------------------------------------------------------------ # Extract embedded information referenced from a track # Inputs: 0) ExifTool ref, 1) tag name, 2) data ref sub ParseTag($$$) { local $_; my ($et, $tag, $dataPt) = @_; my $dataLen = length $$dataPt; if ($tag eq 'stsz' or $tag eq 'stz2' and $dataLen > 12) { # read the sample sizes my ($sz, $num) = unpack('x4N2', $$dataPt); my $size = $$et{ee}{size} = [ ]; if ($tag eq 'stsz') { if ($sz == 0) { @$size = ReadValue($dataPt, 12, 'int32u', $num, $dataLen-12); } else { @$size = ($sz) x $num; } } else { $sz &= 0xff; if ($sz == 4) { my @tmp = ReadValue($dataPt, 12, 'int8u', int(($num+1)/2), $dataLen-12); foreach (@tmp) { push @$size, $_ >> 4; push @$size, $_ & 0xff; } } elsif ($sz == 8 || $sz == 16) { @$size = ReadValue($dataPt, 12, "int${sz}u", $num, $dataLen-12); } } } elsif ($tag eq 'stco' or $tag eq 'co64' and $dataLen > 8) { # read the chunk offsets my $num = unpack('x4N', $$dataPt); my $stco = $$et{ee}{stco} = [ ]; @$stco = ReadValue($dataPt, 8, $tag eq 'stco' ? 'int32u' : 'int64u', $num, $dataLen-8); } elsif ($tag eq 'stsc' and $dataLen > 8) { # read the sample-to-chunk box my $num = unpack('x4N', $$dataPt); if ($dataLen >= 8 + $num * 12) { my ($i, @stsc); for ($i=0; $i<$num; ++$i) { # list of (first-chunk, samples-per-chunk, sample-description-index) push @stsc, [ unpack('x'.(8+$i*12).'N3', $$dataPt) ]; } $$et{ee}{stsc} = \@stsc; } } elsif ($tag eq 'stts' and $dataLen > 8) { # read the time-to-sample box my $num = unpack('x4N', $$dataPt); if ($dataLen >= 8 + $num * 8) { $$et{ee}{stts} = [ unpack('x8N'.($num*2), $$dataPt) ]; } } elsif ($tag eq 'avcC') { # read the AVC compressor configuration $$et{ee}{avcC} = $$dataPt if $dataLen >= 7; # (minimum length is 7) } elsif ($tag eq 'JPEG') { $$et{ee}{JPEG} = $$dataPt; } elsif ($tag eq 'gps ' and $dataLen > 8) { # decode Novatek 'gps ' box (ref 2) my $num = Get32u($dataPt, 4); $num = int(($dataLen - 8) / 8) if $num * 8 + 8 > $dataLen; my $start = $$et{ee}{start} = [ ]; my $size = $$et{ee}{size} = [ ]; my $i; for ($i=0; $i<$num; ++$i) { push @$start, Get32u($dataPt, 8 + $i * 8); push @$size, Get32u($dataPt, 12 + $i * 8); } $$et{HandlerType} = $tag; # fake handler type ProcessSamples($et); # we have all we need to process sample data now } } #------------------------------------------------------------------------------ # Process Yuneec 'tx3g' sbtl metadata (ref PH) # Inputs: 0) ExifTool object ref, 1) dirInfo ref, 2) tag table ref # Returns: 1 on success sub Process_tx3g($$$) { my ($et, $dirInfo, $tagTablePtr) = @_; my $dataPt = $$dirInfo{DataPt}; return 0 if length $$dataPt < 2; pos($$dataPt) = 2; # skip 2-byte length word $et->HandleTag($tagTablePtr, $1, $2) while $$dataPt =~ /(\w+):([^:]*[^:\s])(\s|$)/sg; return 1; } #------------------------------------------------------------------------------ # Process QuickTime 'mebx' timed metadata # Inputs: 0) ExifTool ref, 1) dirInfo ref, 2) tag table ref # Returns: 1 on success # - uses tag ID keys stored in the ExifTool ee data member by a previous call to SaveMetaKeys sub Process_mebx($$$) { my ($et, $dirInfo, $tagTbl) = @_; my $ee = $$et{ee} or return 0; return 0 unless $$ee{'keys'}; my $dataPt = $$dirInfo{DataPt}; # parse using information from 'keys' table (eg. Apple iPhone7+ hevc 'Core Media Data Handler') $et->VerboseDir('mebx', undef, length $$dataPt); my $pos = 0; while ($pos + 8 < length $$dataPt) { my $len = Get32u($dataPt, $pos); last if $len < 8 or $pos + $len > length $$dataPt; my $id = substr($$dataPt, $pos+4, 4); my $info = $$ee{'keys'}{$id}; if ($info) { my $tag = $$info{TagID}; unless ($$tagTbl{$tag}) { next unless $tag =~ /^[-\w.]+$/; # create info for tags with reasonable id's my $name = $tag; $name =~ s/[-.](.)/\U$1/g; AddTagToTable($tagTbl, $tag, { Name => ucfirst($name) }); } my $val = ReadValue($dataPt, $pos+8, $$info{Format}, undef, $len-8); $et->HandleTag($tagTbl, $tag, $val, DataPt => $dataPt, Base => $$dirInfo{Base}, Start => $pos + 8, Size => $len - 8, ); } else { $et->WarnOnce('No key information for mebx ID ' . PrintableTagID($id,1)); } $pos += $len; } return 1; } #------------------------------------------------------------------------------ # Scan movie data for "freeGPS" metadata if not found already (ref PH) # Inputs: 0) ExifTool ref sub ScanMovieData($) { my $et = shift; return if $$et{DOC_COUNT}; # don't scan if we already found embedded metadata my $raf = $$et{RAF} or return; my $dataPos = $$et{VALUE}{MovieDataOffset} or return; my $dataLen = $$et{VALUE}{MovieDataSize} or return; $raf->Seek($dataPos, 0) or return; my ($pos, $buf2) = (0, ''); my ($tagTbl, $oldByteOrder, $verbose, $buff); $$et{FreeGPS2} = { }; # initialize variable space for FreeGPS2() # loop through 'mdat' movie data looking for GPS information for (;;) { last if $pos + $gpsBlockSize > $dataLen; last unless $raf->Read($buff, $gpsBlockSize); $buff = $buf2 . $buff if length $buf2; last if length $buff < $gpsBlockSize; # look for "freeGPS " block # (always found on an absolute 0x8000-byte boundary in all of # my samples, but allow for any alignment when searching) if ($buff !~ /\0\0\x80\0freeGPS /g) { $buf2 = substr($buff,-12); $pos += length($buff)-12; # in all of my samples the first freeGPS block is within 2 MB of the start # of the mdat, so limit the scan to the first 20 MB to be fast and safe next if $tagTbl or $pos < 20e6; last; } elsif (not $tagTbl) { # initialize variables for extracting metadata from this block $tagTbl = GetTagTable('Image::ExifTool::QuickTime::Stream'); $verbose = $$et{OPTIONS}{Verbose}; $oldByteOrder = GetByteOrder(); SetByteOrder('II'); $et->VPrint(0, "---- Extract Embedded ----\n"); $$et{INDENT} .= '| '; } if (pos($buff) > 12) { $pos += pos($buff) - 12; $buff = substr($buff, pos($buff) - 12); } # make sure we have the full 0x8000-byte freeGPS record my $more = $gpsBlockSize - length($buff); if ($more > 0) { last unless $raf->Read($buf2, $more) == $more; $buff .= $buf2; } if ($verbose) { $et->VerboseDir('GPS', undef, $gpsBlockSize); $et->VerboseDump(\$buff, DataPos => $pos + $dataPos); } ProcessFreeGPS2($et, { DataPt => \$buff, DataPos => $pos + $dataPos }, $tagTbl); $pos += $gpsBlockSize; $buf2 = substr($buff, $gpsBlockSize); } if ($tagTbl) { $$et{DOC_NUM} = 0; $et->VPrint(0, "--------------------------\n"); SetByteOrder($oldByteOrder); $$et{INDENT} = substr $$et{INDENT}, 0, -2; } } 1; # end __END__ =head1 NAME Image::ExifTool::QuickTime - Extract embedded information from movie data =head1 SYNOPSIS These routines are autoloaded by Image::ExifTool::QuickTime. =head1 DESCRIPTION This file contains routines used by Image::ExifTool to extract embedded information like GPS tracks from MOV and MP4 movie data. =head1 AUTHOR Copyright 2003-2018, Phil Harvey (phil at owl.phy.queensu.ca) This library is free software; you can redistribute it and/or modify it under the same terms as Perl itself. =head1 REFERENCES =over 4 =item Lhttps://developer.apple.com/library/content/documentation/QuickTime/QTFF/QTFFChap3/qtff3.html#//apple_ref/doc/uid/TP40000939-CH205-SW130> =item L =item L =item L =item L =back =head1 SEE ALSO L, L, L, L, L =cut