test_pie/external/exiftool/lib/Image/ExifTool/QuickTimeStream.pl

1428 lines
57 KiB
Perl

#------------------------------------------------------------------------------
# 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<https://developers.google.com/streetview/publish/camm-spec> 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<http://sergei.nz/files/nvtk_mp42gpx.py>
=item L<https://forum.flitsservice.nl/dashcam-info/dod-ls460w-gps-data-uit-mov-bestand-lezen-t87926.html>
=item L<https://developers.google.com/streetview/publish/camm-spec>
=item L<https://sergei.nz/extracting-gps-data-from-viofo-a119-and-other-novatek-powered-cameras/>
=back
=head1 SEE ALSO
L<Image::ExifTool::QuickTime(3pm)|Image::ExifTool::QuickTime>,
L<Image::ExifTool::TagNames/QuickTime Stream Tags>,
L<Image::ExifTool::TagNames/GoPro GPMF Tags>,
L<Image::ExifTool::TagNames/Sony rtmd Tags>,
L<Image::ExifTool(3pm)|Image::ExifTool>
=cut