| # 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() |