385 lines
15 KiB
Perl
385 lines
15 KiB
Perl
|
#------------------------------------------------------------------------------
|
||
|
# File: WriteQuickTime.pl
|
||
|
#
|
||
|
# Description: Write XMP to QuickTime (MOV and MP4) files
|
||
|
#
|
||
|
# Revisions: 2013-10-29 - P. Harvey Created
|
||
|
#------------------------------------------------------------------------------
|
||
|
package Image::ExifTool::QuickTime;
|
||
|
|
||
|
use strict;
|
||
|
|
||
|
# map for adding directories to QuickTime-format files
|
||
|
my %movMap = (
|
||
|
# MOV (no 'ftyp', or 'ftyp'='qt ') -> 'moov'-'udta'-'XMP_'
|
||
|
XMP => 'UserData',
|
||
|
UserData => 'Movie',
|
||
|
Movie => 'MOV',
|
||
|
);
|
||
|
my %mp4Map = (
|
||
|
# MP4 ('ftyp' compatible brand 'mp41', 'mp42' or 'f4v ') -> top level 'uuid'
|
||
|
XMP => 'MOV',
|
||
|
);
|
||
|
my %dirMap = (
|
||
|
MOV => \%movMap,
|
||
|
MP4 => \%mp4Map,
|
||
|
HEIC => { }, # can't currently write XMP to HEIC files
|
||
|
);
|
||
|
|
||
|
#------------------------------------------------------------------------------
|
||
|
# Check to see if path is current
|
||
|
# Inputs: 0) ExifTool object ref, 1) directory name
|
||
|
# Returns: true if current path is the root of the specified directory
|
||
|
sub IsCurPath($$)
|
||
|
{
|
||
|
local $_;
|
||
|
my ($et, $dir) = @_;
|
||
|
$dir = $$et{DirMap}{$dir} and $dir eq $_ or last foreach reverse @{$$et{PATH}};
|
||
|
return($dir and $dir eq 'MOV');
|
||
|
}
|
||
|
|
||
|
#------------------------------------------------------------------------------
|
||
|
# Write a series of QuickTime atoms from file or in memory
|
||
|
# Inputs: 0) ExifTool ref, 1) dirInfo ref, 2) tag table ref
|
||
|
# Returns: A) if dirInfo contains DataPt: new directory data
|
||
|
# B) otherwise: true on success, 0 if a write error occurred
|
||
|
# (true but sets an Error on a file format error)
|
||
|
sub WriteQuickTime($$$)
|
||
|
{
|
||
|
my ($et, $dirInfo, $tagTablePtr) = @_;
|
||
|
my ($foundMDAT, $lengthChanged, @hold, $track);
|
||
|
my $outfile = $$dirInfo{OutFile} or return 0;
|
||
|
my $raf = $$dirInfo{RAF};
|
||
|
my $dataPt = $$dirInfo{DataPt};
|
||
|
my $dirName = $$dirInfo{DirName};
|
||
|
my $parent = $$dirInfo{Parent};
|
||
|
my $addDirs = $$et{ADD_DIRS};
|
||
|
my $rtnVal = 1;
|
||
|
|
||
|
if ($dataPt) {
|
||
|
$raf = new File::RandomAccess($dataPt);
|
||
|
my $outBuff = '';
|
||
|
$outfile = \$outBuff;
|
||
|
} else {
|
||
|
return 0 unless $raf;
|
||
|
}
|
||
|
for (;;) {
|
||
|
my ($hdr, $buff);
|
||
|
my $n = $raf->Read($hdr, 8);
|
||
|
unless ($n == 8) {
|
||
|
if ($n == 4 and $hdr eq "\0\0\0\0") {
|
||
|
# "for historical reasons" the udta is optionally terminated by 4 zeros (ref 1)
|
||
|
# --> hold this terminator to the end
|
||
|
push @hold, $hdr;
|
||
|
} elsif ($n != 0) {
|
||
|
$et->Error('File format error');
|
||
|
}
|
||
|
last;
|
||
|
}
|
||
|
my ($size, $tag) = unpack('Na4', $hdr);
|
||
|
if ($size >= 8) {
|
||
|
$size -= 8;
|
||
|
} elsif ($size == 1) {
|
||
|
# read the extended size
|
||
|
$raf->Read($buff, 8) == 8 or $et->Error('Truncated extended atom'), last;
|
||
|
$hdr .= $buff;
|
||
|
my ($hi, $lo) = unpack('NN', $buff);
|
||
|
$size = $hi * 4294967296 + $lo - 16;
|
||
|
$size < 0 and $et->Error('Invalid extended atom size'), last;
|
||
|
} elsif (not $size and not $dataPt) {
|
||
|
# size of zero is only valid for top-level atom, and
|
||
|
# indicates the atom extends to the end of file
|
||
|
if (not $raf->{FILE_PT}) {
|
||
|
# get file size from image in memory
|
||
|
$size = length ${$$raf{BUFF_PT}};
|
||
|
} else {
|
||
|
$size = -s $$raf{FILE_PT};
|
||
|
}
|
||
|
if ($size and ($size -= $raf->Tell()) >= 0 and $size <= 0x7fffffff) {
|
||
|
Set32u($size + 8, \$hdr, 0);
|
||
|
} elsif (@hold) {
|
||
|
$et->Error("Sorry, can't yet add tags to this type of QuickTime file");
|
||
|
return $rtnVal;
|
||
|
} else {
|
||
|
# blindly copy the rest of the file
|
||
|
Write($outfile, $hdr) or $rtnVal = 0;
|
||
|
while ($raf->Read($buff, 65536)) {
|
||
|
Write($outfile, $buff) or $rtnVal = 0, last;
|
||
|
}
|
||
|
return $rtnVal;
|
||
|
}
|
||
|
} else {
|
||
|
$et->Error('Invalid atom size');
|
||
|
last;
|
||
|
}
|
||
|
|
||
|
# set flag if we have passed the 'mdat' atom
|
||
|
if ($tag eq 'mdat') {
|
||
|
if ($dataPt) {
|
||
|
$et->Error("'mdat' not at top level");
|
||
|
} elsif ($foundMDAT and $foundMDAT == 1 and $lengthChanged and
|
||
|
not $et->Options('FixCorruptedMOV'))
|
||
|
{
|
||
|
$et->Error("Multiple 'mdat' blocks! Can only edit existing tags");
|
||
|
$foundMDAT = 2;
|
||
|
} else {
|
||
|
$foundMDAT = 1;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
# rewrite this atom
|
||
|
my $tagInfo = $et->GetTagInfo($tagTablePtr, $tag);
|
||
|
if (defined $tagInfo and not $tagInfo) {
|
||
|
my $n = $size < 256 ? $size : 256;
|
||
|
unless ($raf->Read($buff, $n) == $n and $raf->Seek(-$n, 1)) {
|
||
|
$et->Error("Read/seek error in $tag atom");
|
||
|
last;
|
||
|
}
|
||
|
$tagInfo = $et->GetTagInfo($tagTablePtr, $tag, \$buff);
|
||
|
}
|
||
|
if ($tagInfo) {
|
||
|
if ($$tagInfo{Unknown}) {
|
||
|
undef $tagInfo;
|
||
|
} elsif ($size > 100000000) {
|
||
|
# limit maximum size of atom that we load into memory
|
||
|
my $mb = $size / 0x100000;
|
||
|
$et->Warn("Not editing metadata in $tag atom. $mb MB is too big");
|
||
|
undef $tagInfo;
|
||
|
}
|
||
|
}
|
||
|
if ($tagInfo and (not defined $$tagInfo{Writable} or $$tagInfo{Writable})) {
|
||
|
# read the atom data
|
||
|
$raf->Read($buff, $size) == $size or $et->Error("Error reading $tag data"), last;
|
||
|
my $subdir = $$tagInfo{SubDirectory};
|
||
|
my $newData;
|
||
|
if ($subdir) {
|
||
|
my $subName = $$subdir{DirName} || $$tagInfo{Name};
|
||
|
my $start = $$subdir{Start} || 0;
|
||
|
my $base = ($$dirInfo{Base} || 0) + $raf->Tell() - $size;
|
||
|
my $dPos = 0;
|
||
|
my $hdrLen = $start;
|
||
|
if ($$subdir{Base}) {
|
||
|
my $localBase = eval $$subdir{Base};
|
||
|
$dPos -= $localBase;
|
||
|
$base -= $dPos;
|
||
|
# get length of header before base offset
|
||
|
$hdrLen -= $localBase if $localBase <= $hdrLen;
|
||
|
}
|
||
|
my %subdirInfo = (
|
||
|
Parent => $dirName,
|
||
|
DirName => $subName,
|
||
|
DataPt => \$buff,
|
||
|
DataLen => $size,
|
||
|
DataPos => $dPos,
|
||
|
DirStart => $start,
|
||
|
DirLen => $size - $start,
|
||
|
Base => $base,
|
||
|
HasData => $$subdir{HasData}, # necessary?
|
||
|
Multi => $$subdir{Multi}, # necessary?
|
||
|
OutFile => $outfile,
|
||
|
InPlace => 2, # (to write fixed-length XMP if possible)
|
||
|
);
|
||
|
# pass the header pointer if necessary (for EXIF IFD's
|
||
|
# where the Base offset is at the end of the header)
|
||
|
if ($hdrLen and $hdrLen < $size) {
|
||
|
my $header = substr($buff,0,$hdrLen);
|
||
|
$subdirInfo{HeaderPtr} = \$header;
|
||
|
}
|
||
|
SetByteOrder('II') if $$subdir{ByteOrder} and $$subdir{ByteOrder} =~ /^Little/;
|
||
|
my $oldWriteGroup = $$et{CUR_WRITE_GROUP};
|
||
|
if ($subName eq 'Track') {
|
||
|
$track or $track = 0;
|
||
|
$$et{CUR_WRITE_GROUP} = 'Track' . (++$track);
|
||
|
}
|
||
|
my $subTable = GetTagTable($$subdir{TagTable});
|
||
|
# demote non-QuickTime errors to warnings
|
||
|
$$et{DemoteErrors} = 1 unless $$subTable{GROUPS}{0} eq 'QuickTime';
|
||
|
my $oldChanged = $$et{CHANGED};
|
||
|
$newData = $et->WriteDirectory(\%subdirInfo, $subTable);
|
||
|
if ($$et{DemoteErrors}) {
|
||
|
# just copy existing subdirectory a non-quicktime error occurred
|
||
|
$$et{CHANGED} = $oldChanged if $$et{DemoteErrors} > 1;
|
||
|
delete $$et{DemoteErrors};
|
||
|
}
|
||
|
undef $newData if $$et{CHANGED} == $oldChanged; # don't change unless necessary
|
||
|
$$et{CUR_WRITE_GROUP} = $oldWriteGroup;
|
||
|
SetByteOrder('MM');
|
||
|
# add back header if necessary
|
||
|
if ($start and defined $newData and length $newData) {
|
||
|
$newData = substr($buff,0,$start) . $newData;
|
||
|
}
|
||
|
# the directory exists, so we don't need to add it
|
||
|
delete $$addDirs{$subName} if IsCurPath($et, $subName);
|
||
|
} else {
|
||
|
# --> this is where individual QuickTime tags would be edited,
|
||
|
# (this is such a can of worms, so don't implement this for now)
|
||
|
}
|
||
|
if (defined $newData) {
|
||
|
my $len = length $newData;
|
||
|
$len > 0x7ffffff7 and $et->Error("$tag to large to write"), last;
|
||
|
if ($len == $size or $dataPt or $foundMDAT) {
|
||
|
# write the updated directory now (unless length is zero, or it is needed as padding)
|
||
|
if ($len or (not $dataPt and not $foundMDAT) or
|
||
|
($et->Options('FixCorruptedMOV') and $tag eq 'udta'))
|
||
|
{
|
||
|
Write($outfile, Set32u($len+8), $tag, $newData) or $rtnVal = 0, last;
|
||
|
$lengthChanged = 1 if $len != $size;
|
||
|
} else {
|
||
|
$lengthChanged = 1; # (we deleted this atom)
|
||
|
}
|
||
|
next;
|
||
|
} else {
|
||
|
# bad things happen if 'mdat' atom is moved (eg. Adobe Bridge crashes --
|
||
|
# there are absolute offsets that point into mdat), so hold this atom
|
||
|
# and write it out later
|
||
|
if ($len) {
|
||
|
push @hold, Set32u($len+8), $tag, $newData;
|
||
|
$et->VPrint(0," Moving '${tag}' atom to after 'mdat'");
|
||
|
} else {
|
||
|
$et->VPrint(0," Freeing '${tag}' atom (and zeroing data)");
|
||
|
}
|
||
|
# write a 'free' atom here to keep 'mdat' at the same offset
|
||
|
substr($hdr, 4, 4) = 'free';
|
||
|
$buff = "\0" x length($buff); # zero out old data
|
||
|
}
|
||
|
}
|
||
|
# write out the existing atom (or 'free' padding)
|
||
|
Write($outfile, $hdr, $buff) or $rtnVal = 0, last;
|
||
|
} else {
|
||
|
# write the unknown/large atom header
|
||
|
Write($outfile, $hdr) or $rtnVal = 0, last;
|
||
|
next unless $size;
|
||
|
# copy the atom data
|
||
|
my $result = Image::ExifTool::CopyBlock($raf, $outfile, $size);
|
||
|
defined $result or $rtnVal = 0, last;
|
||
|
$result or $et->Error("Truncated $tag atom"), last;
|
||
|
}
|
||
|
}
|
||
|
# add new directories at this level if necessary
|
||
|
if (exists $$et{EDIT_DIRS}{$dirName}) {
|
||
|
# get a hash of tagInfo references to add to this directory
|
||
|
my $dirs = $et->GetAddDirHash($tagTablePtr, $dirName);
|
||
|
# make sorted list of new tags to be added
|
||
|
my @addTags = sort keys(%$dirs);
|
||
|
my $tag;
|
||
|
foreach $tag (@addTags) {
|
||
|
my $tagInfo = $$dirs{$tag};
|
||
|
my $subdir = $$tagInfo{SubDirectory} or next;
|
||
|
my $subName = $$subdir{DirName} || $$tagInfo{Name};
|
||
|
# QuickTime hierarchy is complex, so check full directory path before adding
|
||
|
next unless IsCurPath($et, $subName);
|
||
|
my $buff = ''; # write from scratch
|
||
|
my %subdirInfo = (
|
||
|
Parent => $dirName,
|
||
|
DirName => $subName,
|
||
|
DataPt => \$buff,
|
||
|
DirStart => 0,
|
||
|
OutFile => $outfile,
|
||
|
);
|
||
|
my $subTable = GetTagTable($$subdir{TagTable});
|
||
|
my $newData = $et->WriteDirectory(\%subdirInfo, $subTable);
|
||
|
if ($newData and length($newData) <= 0x7ffffff7) {
|
||
|
my $uuid = '';
|
||
|
# add atom ID if necessary (obtain from Condition expression)
|
||
|
if ($$subdir{Start}) {
|
||
|
my $cond = $$tagInfo{Condition};
|
||
|
$uuid = eval qq("$1") if $cond and $cond =~ m{=~\s*\/\^(.*)/};
|
||
|
length($uuid) == $$subdir{Start} or $et->Error('Internal UUID error');
|
||
|
}
|
||
|
my $newHdr = Set32u(8+length($newData)+length($uuid)) . $tag . $uuid;
|
||
|
Write($outfile, $newHdr, $newData) or $rtnVal = 0;
|
||
|
$lengthChanged = 1;
|
||
|
}
|
||
|
delete $$addDirs{$subName}; # add only once (must delete _after_ call to WriteDirectory())
|
||
|
}
|
||
|
}
|
||
|
# write out any atoms that we are holding until the end
|
||
|
Write($outfile, @hold) or $rtnVal = 0 if @hold;
|
||
|
|
||
|
# issue minor error if we didn't find an 'mdat' atom
|
||
|
# (we could duplicate atoms indefinitely through repeated editing if we
|
||
|
# held back some atoms here, so in this case it isn't a minor error)
|
||
|
$dataPt or $foundMDAT or $et->Error('No mdat atom found', @hold ? 0 : 1);
|
||
|
|
||
|
return $dataPt ? ($rtnVal ? $$outfile : undef) : $rtnVal;
|
||
|
}
|
||
|
|
||
|
#------------------------------------------------------------------------------
|
||
|
# Write QuickTime-format MOV/MP4 file
|
||
|
# Inputs: 0) ExifTool ref, 1) dirInfo ref
|
||
|
# Returns: 1 on success, 0 if this wasn't a valid QuickTime file,
|
||
|
# or -1 if a write error occurred
|
||
|
sub WriteMOV($$)
|
||
|
{
|
||
|
my ($et, $dirInfo) = @_;
|
||
|
$et or return 1;
|
||
|
my $raf = $$dirInfo{RAF} or return 0;
|
||
|
my ($buff, $ftype);
|
||
|
|
||
|
# read the first atom header
|
||
|
return 0 unless $raf->Read($buff, 8) == 8;
|
||
|
my ($size, $tag) = unpack('Na4', $buff);
|
||
|
return 0 if $size < 8 and $size != 1;
|
||
|
|
||
|
# validate the file format
|
||
|
my $tagTablePtr = GetTagTable('Image::ExifTool::QuickTime::Main');
|
||
|
return 0 unless $$tagTablePtr{$tag};
|
||
|
|
||
|
# determine the file type
|
||
|
if ($tag eq 'ftyp' and $size >= 12 and $size < 100000 and
|
||
|
$raf->Read($buff, $size-8) == $size-8 and
|
||
|
$buff !~ /^(....)+(qt )/s)
|
||
|
{
|
||
|
# file is MP4 format if 'ftyp' exists without 'qt ' as a compatible brand
|
||
|
if ($buff =~ /^(heic|mif1|msf1|heix|hevc|hevx)/) {
|
||
|
$ftype = 'HEIC';
|
||
|
$et->Error("Can't currently write HEIC/HEIF files");
|
||
|
} else {
|
||
|
$ftype = 'MP4';
|
||
|
}
|
||
|
} else {
|
||
|
$ftype = 'MOV';
|
||
|
}
|
||
|
$et->SetFileType($ftype); # need to set "FileType" tag for a Condition
|
||
|
$et->InitWriteDirs($dirMap{$ftype}, 'XMP');
|
||
|
$$et{DirMap} = $dirMap{$ftype}; # need access to directory map when writing
|
||
|
SetByteOrder('MM');
|
||
|
$raf->Seek(0,0);
|
||
|
|
||
|
# write the file
|
||
|
$$dirInfo{Parent} = '';
|
||
|
$$dirInfo{DirName} = 'MOV';
|
||
|
return WriteQuickTime($et, $dirInfo, $tagTablePtr) ? 1 : -1;
|
||
|
}
|
||
|
|
||
|
1; # end
|
||
|
|
||
|
__END__
|
||
|
|
||
|
=head1 NAME
|
||
|
|
||
|
Image::ExifTool::WriteQuickTime.pl - Write XMP to QuickTime (MOV and MP4) files
|
||
|
|
||
|
=head1 SYNOPSIS
|
||
|
|
||
|
These routines are autoloaded by Image::ExifTool::QuickTime.
|
||
|
|
||
|
=head1 DESCRIPTION
|
||
|
|
||
|
This file contains routines used by ExifTool to write XMP metadata to
|
||
|
QuickTime-based file formats like MOV and MP4.
|
||
|
|
||
|
=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 SEE ALSO
|
||
|
|
||
|
L<Image::ExifTool::QuickTime(3pm)|Image::ExifTool::QuickTime>,
|
||
|
L<Image::ExifTool(3pm)|Image::ExifTool>
|
||
|
|
||
|
=cut
|