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