Add fix_essential script to help endusers with old, incorrectly written AVIFs
diff --git a/contrib/fix_essential.coffee b/contrib/fix_essential.coffee
new file mode 100644
index 0000000..c715b21
--- /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, "<file>", 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()