diff --git a/contrib/fix_essential.coffee b/contrib/fix_essential.coffee new file mode 100644 index 0000000000..c715b21708 --- /dev/null +++ b/contrib/fix_essential.coffee @@ -0,0 +1,278 @@ +# Copyright 2021 Joe Drago. All rights reserved. +# SPDX-License-Identifier: BSD-2-Clause + +# READ THIS WHOLE COMMENT FIRST, BEFORE RUNNING THIS SCRIPT: + +# AVIFs created with very old copies of avifenc (versions prior to v0.7.2) did not correctly set the +# "essential" flag on av1C item property associations. This is likely to cause future AVIF decoders +# (including libavif/avifdec!) to refuse to parse them. Luckily, this is an easy thing to adjust +# in-place in an affected AVIF, and does not change the file's size (it just toggles a bit or two). + +# The goal of this script is to detect AVIFs containing item property associations that are not +# flagged as "essential" but should be, and fix those essential flags in-place by re-writing the +# file. The syntax is simple: + +# coffee fix_essential.coffee filename.avif + +# This will look over the associations and if it detects an incorrect essential flag, it will fix it +# in memory, make a adjacent backup of the file (filename.avif.essentialBackup), and then overwrite +# the original file with the fixed contents. Using -v on the commandline will enable Verbose mode, +# and using -n will disable the creation of backups (.essentialBackup files). + +# This should be well-behaved on files created by avifenc prior to version v0.7.2 (when these +# erroneous bits could be set), but **PLEASE** make backups of your images before running this +# script on them, **especially** if you plan to run with "-n". + +# Possible responses for a file: +# * [NotAvif] This file isn't an AVIF. +# * [BadAvif] This file thinks it is an AVIF, but is missing important things. +# * [Skipped] This file is an AVIF, but didn't need any fixes. +# * [Success] This file is an AVIF, had to be fixed, and was fixed. +# * (the script crashes) I probably have a bug; let me know. + +# ------------------------------------------------------------------------------------------------- +# Syntax + +syntax = -> + console.log "Syntax: fix_essential [-v] [-n] file1 [file2 ...]" + console.log " -v : Verbose mode" + console.log " -n : No Backups (Don't generate adjacent .essentialBackup files when overwriting in-place)" + +# ------------------------------------------------------------------------------------------------- +# Constants and helpers + +fs = require 'fs' + +INDENT = " " +VERBOSE = false + +verboseLog = -> + if VERBOSE + console.log.apply(null, arguments) + +fatalError = (reason) -> + console.error "ERROR: #{reason}" + process.exit(1) + +# ------------------------------------------------------------------------------------------------- +# Box + +class Box + constructor: (@filename, @type, @buffer, @start, @size) -> + @offset = @start + @bytesLeft = @size + @version = 0 + @flags = 0 + @boxes = {} # child boxes + + nextBox: -> + if @bytesLeft < 8 + return null + boxSize = @buffer.readUInt32BE(@offset) + boxType = @buffer.toString('utf8', @offset + 4, @offset + 8) + if boxSize > @bytesLeft + verboseLog("#{INDENT} * Truncated box of type #{boxType} (#{boxSize} bytes with only #{@bytesLeft} bytes left)") + return null + if boxSize < 8 + verboseLog("#{INDENT} * Bad box size of type #{boxType} (#{boxSize} bytes") + return null + newBox = new Box(@filename, boxType, @buffer, @offset + 8, boxSize - 8) + @offset += boxSize + @bytesLeft -= boxSize + return newBox + + walkBoxes: -> + while box = @nextBox() + @boxes[box.type] = box + verboseLog "#{INDENT} * Discovered box type: #{box.type} offset: #{box.offset - 8} size: #{box.size + 8}" + return + + readFullBoxHeader: -> + if @bytesLeft < 4 + fatalError("#{INDENT} * Truncated FullBox of type #{boxType} (only #{@bytesLeft} bytes left)") + versionAndFlags = @buffer.readUInt32BE(@offset) + @version = (versionAndFlags >> 24) & 0xFF + @flags = versionAndFlags & 0xFFFFFF + @offset += 4 + @bytesLeft -= 4 + return + + # Replaces the most recently read U8 with a new value + fixU8: (newValue) -> + if @offset < 1 + fatalError("#{INDENT} * impossible call to fixU8!") + @buffer.writeUInt8(newValue, @offset - 1) + + readU8: -> + if @bytesLeft < 1 + fatalError("#{INDENT} * Truncated read of U8 from box of type #{boxType} (only #{@bytesLeft} bytes left)") + ret = @buffer.readUInt8(@offset) + @offset += 1 + @bytesLeft -= 1 + return ret + + readU16: -> + if @bytesLeft < 2 + fatalError("#{INDENT} * Truncated read of U16 from box of type #{boxType} (only #{@bytesLeft} bytes left)") + ret = @buffer.readUInt16BE(@offset) + @offset += 2 + @bytesLeft -= 2 + return ret + + readU32: -> + if @bytesLeft < 4 + fatalError("#{INDENT} * Truncated read of U32 from box of type #{boxType} (only #{@bytesLeft} bytes left)") + ret = @buffer.readUInt32BE(@offset) + @offset += 4 + @bytesLeft -= 4 + return ret + + ftypHasBrand: (brand) -> + if @type != 'ftyp' + fatalError("#{INDENT} * Calling Box.ftypHasBrand() on a non-ftyp box") + majorBrand = @buffer.toString('utf8', @offset, @offset + 4) + compatibleBrands = [] + compatibleBrandCount = Math.floor((@size - 8) / 4) + for i in [0...compatibleBrandCount] + o = @offset + 8 + (i * 4) + compatibleBrand = @buffer.toString('utf8', o, o + 4) + compatibleBrands.push compatibleBrand + + verboseLog "#{INDENT} * ftyp majorBrand: #{majorBrand} compatibleBrands: [#{compatibleBrands.join(', ')}]" + + if majorBrand == brand + return true + for compatibleBrand in compatibleBrands + if compatibleBrand == brand + return true + return false + +# ------------------------------------------------------------------------------------------------- +# Main + +fixEssential = (filename, makeBackups) -> + if not fs.existsSync(filename) + fatalError("File doesn't exist: #{filename}") + try + fileBuffer = fs.readFileSync(filename) + catch e + fatalError "Failed to read \"#{filename}\": #{e}" + + fileBox = new Box(filename, "", fileBuffer, 0, fileBuffer.length) + fileBox.walkBoxes() + + ftypBox = fileBox.boxes.ftyp + if not ftypBox? + return "NotAvif" + if ftypBox.type != 'ftyp' + return "NotAvif" + if !ftypBox.ftypHasBrand('avif') + return "NotAvif" + + metaBox = fileBox.boxes.meta + if not metaBox? + return "BadAvif" + metaBox.readFullBoxHeader() + metaBox.walkBoxes() + + iprpBox = metaBox.boxes.iprp + if not iprpBox? + return "BadAvif" + + ipcoBox = null + ipmaBoxes = [] + while box = iprpBox.nextBox() + if box.type == 'ipco' + if ipcoBox? + fatalError("#{INDENT} * Multiple ipco boxes found in a single ipma box!") + ipcoBox = box + else if box.type == 'ipma' + ipmaBoxes.push box + if not ipcoBox? or (ipmaBoxes.length == 0) + return "BadAvif" + + properties = {} + propertyIndex = 0 + while box = ipcoBox.nextBox() + propertyIndex += 1 + properties[propertyIndex] = + type: box.type + essential: false + switch box.type + when 'av1C', 'lsel', 'clap', 'irot', 'imir' + properties[propertyIndex].essential = true + + fixedBit = false + for ipmaBox in ipmaBoxes + ipmaBox.readFullBoxHeader() + ipmaEntryCount = ipmaBox.readU32() + for ipmaEntryIndex in [0...ipmaEntryCount] + if ipmaBox.version < 1 + itemID = ipmaBox.readU16() + else + itemID = ipmaBox.readU32() + associationCount = ipmaBox.readU8() + verboseLog "#{INDENT} * Item ID #{itemID} has #{associationCount} associations" + for associationIndex in [0...associationCount] + if ipmaBox.flags & 0x1 + essentialAndIndex = ipmaBox.readU16() + essentialBit = ((essentialAndIndex & 0x8000) != 0) + index = essentialAndIndex & 0x7FFF + else + essentialAndIndex = ipmaBox.readU8() + essentialBit = ((essentialAndIndex & 0x80) != 0) + index = essentialAndIndex & 0x7F + if not properties[index]? + fatalError("#{INDENT} * Impossible property index #{index}") + if properties[index].essential + if essentialBit == 0 + state = "Bad" + else + state = "Good" + else + state = "OK" + verboseLog "#{INDENT} * #{associationIndex} -> index: #{index} (#{properties[index].type}), #{if essentialBit > 0 then "essential" else "non-essential"} [#{state}]" + if not essentialBit and properties[index].essential + verboseLog "#{INDENT} * Fixing index #{index}" + fixedBit = true + fixedEssentialAndIndex = index | 0x80 + ipmaBox.fixU8(fixedEssentialAndIndex) + + if fixedBit + if makeBackups + backupFilename = filename + ".essentialBackup" + fs.writeFileSync(backupFilename, fs.readFileSync(filename)) + fs.writeFileSync(filename, fileBuffer) + return "Success" + return "Skipped" + +main = -> + showSyntax = false + makeBackups = true + files = [] + + for arg in process.argv.slice(2) + switch arg + when '-h', '--help' + showSyntax = true + break + when '-n', '--no-backups' + makeBackups = false + break + when '-v', '--verbose' + VERBOSE = true + break + else + files.push arg + + if showSyntax or files.length == 0 + return syntax() + + for filename in files + verboseLog("[Reading] #{filename}") + result = fixEssential(filename, makeBackups) + console.log("[#{result}] #{filename}") # Always print this + + return 0 + +main() diff --git a/src/read.c b/src/read.c index 44ee044669..d1b4b452ab 100644 --- a/src/read.c +++ b/src/read.c @@ -1471,7 +1471,7 @@ static avifBool avifParseItemPropertyAssociation(avifMeta * meta, const uint8_t // Copy property to item avifProperty * srcProp = &meta->properties.prop[propertyIndex]; - static const char * supportedTypes[] = { "ispe", "auxC", "colr", "av1C", "pasp", "clap", "irot", "imir", "pixi" }; + static const char * const supportedTypes[] = { "ispe", "auxC", "colr", "av1C", "pasp", "clap", "irot", "imir", "pixi" }; size_t supportedTypesCount = sizeof(supportedTypes) / sizeof(supportedTypes[0]); avifBool supportedType = AVIF_FALSE; for (size_t i = 0; i < supportedTypesCount; ++i) { @@ -1481,6 +1481,33 @@ static avifBool avifParseItemPropertyAssociation(avifMeta * meta, const uint8_t } } if (supportedType) { + if (!essential) { + // Verify that it is legal for this property to not be flagged as essential. Any + // types in this list are *required* in the spec to be flagged as essential when + // associated with an item. + static const char * const essentialTypes[] = { + + // AVIF: Section 2.2.1: "This property shall be marked as essential." + "av1C", + + // MIAF: Section 7.3.9: "All transformative properties associated with coded + // and derived images required or conditionally required by this document + // shall be marked as essential" + "clap", + "irot", + "imir" + + }; + size_t essentialTypesCount = sizeof(essentialTypes) / sizeof(essentialTypes[0]); + for (size_t i = 0; i < essentialTypesCount; ++i) { + if (!memcmp(srcProp->type, essentialTypes[i], 4)) { + // An essential-required property is not flagged as essential, bail out + return AVIF_FALSE; + } + } + } + + // Supported and valid; associate it with this item. avifProperty * dstProp = (avifProperty *)avifArrayPushPtr(&item->properties); memcpy(dstProp, srcProp, sizeof(avifProperty)); } else { diff --git a/tests/data/io/extentsalpha.avif b/tests/data/io/extentsalpha.avif index 53760c90fa..fbcdaab420 100644 Binary files a/tests/data/io/extentsalpha.avif and b/tests/data/io/extentsalpha.avif differ diff --git a/tests/data/io/twoextents.avif b/tests/data/io/twoextents.avif index a17a822b0e..8e72aecd08 100644 Binary files a/tests/data/io/twoextents.avif and b/tests/data/io/twoextents.avif differ diff --git a/tests/data/originals/cosmos1650_yuv444_10bpc_p3pq.avif b/tests/data/originals/cosmos1650_yuv444_10bpc_p3pq.avif index 79d1ec7a43..f628a0528f 100644 Binary files a/tests/data/originals/cosmos1650_yuv444_10bpc_p3pq.avif and b/tests/data/originals/cosmos1650_yuv444_10bpc_p3pq.avif differ diff --git a/tests/data/originals/kodim03_yuv420_8bpc.avif b/tests/data/originals/kodim03_yuv420_8bpc.avif index 0810e3497d..337febc600 100644 Binary files a/tests/data/originals/kodim03_yuv420_8bpc.avif and b/tests/data/originals/kodim03_yuv420_8bpc.avif differ diff --git a/tests/data/originals/kodim23_yuv420_8bpc.avif b/tests/data/originals/kodim23_yuv420_8bpc.avif index d9e619a360..d4eebb646f 100644 Binary files a/tests/data/originals/kodim23_yuv420_8bpc.avif and b/tests/data/originals/kodim23_yuv420_8bpc.avif differ