| # Copyright 2021 Joe Drago. All rights reserved. | 
 | # SPDX-License-Identifier: BSD-2-Clause | 
 |  | 
 | # READ THIS WHOLE COMMENT FIRST, BEFORE RUNNING THIS SCRIPT: | 
 |  | 
 | # The goal of this script is to detect AVIFs containing multiple adjacent iref boxes and merge them, | 
 | # filling leftover space with a free box (to avoid ruining file offsets elsewhere in the file). The | 
 | # syntax is simple: | 
 |  | 
 | #     coffee irefmerge.coffee filename.avif | 
 |  | 
 | # This will look over the file's contents and if it detects multiple irefs, it will fix it in | 
 | # memory, make a adjacent backup of the file (filename.avif.irefmergeBackup), 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 (.irefmergeBackup files). | 
 |  | 
 | # This should be well-behaved on files created by old versions of avifenc, but **PLEASE** make | 
 | # backups of your images before running this script on them, **especially** if you plan to run with | 
 | # "-n". I do not advise running this script on AVIFs generated by anything other than avifenc. | 
 |  | 
 | # 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. | 
 |  | 
 | # Note on CoffeeScript: | 
 | # If you don't want to invoke coffeescript every time, you can compile it once with: | 
 | #     coffee -c -b irefmerge.coffee | 
 | # ... and run the adjacent irefmerge.js with node instead. "It's just JavaScript." | 
 |  | 
 | # ------------------------------------------------------------------------------------------------- | 
 | # Syntax | 
 |  | 
 | syntax = -> | 
 |   console.log "Syntax: irefmerge [-v] [-n] file1 [file2 ...]" | 
 |   console.log "        -v : Verbose mode" | 
 |   console.log "        -n : No Backups (Don't generate adjacent .irefmergeBackup 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 | 
 |     verboseLog "#{INDENT} * Discovered box type: #{newBox.type} offset: #{newBox.offset - 8} size: #{newBox.size + 8}" | 
 |     return newBox | 
 |  | 
 |   walkBoxes: -> | 
 |     while box = @nextBox() | 
 |       @boxes[box.type] = box | 
 |     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 | 
 |  | 
 |   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 | 
 |  | 
 | irefMerge = (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() | 
 |  | 
 |   merged = false | 
 |   irefs = [] | 
 |   while box = metaBox.nextBox() | 
 |     if box.type == 'iref' | 
 |       irefs.push box | 
 |  | 
 |   # console.log irefs | 
 |   if irefs.length > 1 | 
 |     verboseLog "#{INDENT} * Discovered multiple (#{irefs.length}) iref boxes, merging..." | 
 |     # merge irefs, and leave a free block in the dead space | 
 |     newTotalSize = 8 + 4 # the new single iref header's size + fullbox | 
 |     for iref in irefs | 
 |       newTotalSize += iref.size - 4 | 
 |     fileBuffer.writeUInt32BE(newTotalSize, irefs[0].start - 8) | 
 |  | 
 |     writeOffset = irefs[0].start + 4 # skip past the fullbox's version[1]+flags[3] | 
 |     for iref in irefs | 
 |       fileBuffer.copy(fileBuffer, writeOffset, iref.start + 4, iref.start + iref.size) | 
 |       writeOffset += iref.size - 4 | 
 |     freeBoxSize = (irefs.length - 1) * 12 | 
 |     freeBox = Buffer.alloc(freeBoxSize) | 
 |     freeBox.fill(0) | 
 |     freeBox.writeUInt32BE(freeBoxSize) | 
 |     freeBox.write("free", 4) | 
 |     freeBox.copy(fileBuffer, writeOffset, 0, freeBoxSize) | 
 |     verboseLog "#{INDENT}   * Wrote a free chunk of size #{freeBoxSize} at offset #{writeOffset}" | 
 |     merged = true | 
 |  | 
 |   if merged | 
 |     if makeBackups | 
 |       backupFilename = filename + ".irefmergeBackup" | 
 |       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 = irefMerge(filename, makeBackups) | 
 |     console.log("[#{result}] #{filename}") # Always print this | 
 |  | 
 |   return 0 | 
 |  | 
 | main() |