public class OptionsObject {

	private static final Logger log = LoggerFactory.getLogger(OptionsObject.class);
	
	/**
	 * The version of preferences
	 */
	@PreferencesField
	private int prefsVersion = 1;
	
	/**
	 * The number of starts this app has had
	 */
	@PreferencesField
	private int starts = 0;
	
	/**
	 * Path to the VLC binary
	 */
	@PreferencesField
	private String pathToVideoEngineLibrary = null;

	/**
	 * The number of frames to sample from the video per second
	 */
	@PreferencesField
	private volatile double videoFramesPerSecond = 10;

	/**
	 * The number of milliseconds delay for each gif frame
	 */
	@PreferencesField
	private volatile int gifDisplayTimeMillis = 100;

	/**
	 * CPU time is diverted more to processing than rendering or other less important tasks
	 */
	@PreferencesField
	private volatile boolean turboMode = false;

	/**
	 * Prefix identifier for custom basic loaders
	 */
	public final static String CUSTOM_LOADER_PREFIX = getCaption("loader_custom") + " ";

	/**
	 * Basic loader for slideshows/video (tap output)
	 */
	private final List<BasicLoader> basicLoaders;
	{
		basicLoaders = new ArrayList<>();
		basicLoaders.add(new BasicLoader(getCaption("loader_simple"), "/simple.tap"));
		basicLoaders.add(new BasicLoader(getCaption("loader_buffered"), "/buffered.tap"));
		basicLoaders.add(new BasicLoader(getCaption("loader_gigascreen"), "/gigascreen.tap"));
		basicLoaders.add(new BasicLoader(CUSTOM_LOADER_PREFIX, null));
	}

	/**
	 * The chosen basic loader
	 */
	@PreferencesField
	private volatile int basicLoader = 0;

	/**
	 * Error diffusion dither strategies available
	 */
	private final List<ErrorDiffusionDitherStrategy> errorDithers;
	{
		errorDithers = new ArrayList<>();
		errorDithers.add(new AtkinsonDitherStrategy());
		errorDithers.add(new BurkesDitherStrategy());
		errorDithers.add(new FloydSteinbergDitherStrategy());
		errorDithers.add(new JarvisJudiceNinkeDitherStrategy());
		errorDithers.add(new LowErrorAtkinsonDitherStrategy());
		errorDithers.add(new NoDitherStrategy());
		errorDithers.add(new SierraFilterLightStrategy());
		errorDithers.add(new StuckiDitherStrategy());
	}

	/**
	 * Ordered dither strategies available
	 */
	private final List<OrderedDitherStrategy> orderedDithers;
	{
		orderedDithers = new ArrayList<>();
		orderedDithers.add(new BayerTwoByOneOrderedDitherStrategy());
		orderedDithers.add(new BayerTwoByTwoOrderedDitherStrategy());
		orderedDithers.add(new OmegaOrderedDitherStrategy()); 
		orderedDithers.add(new BayerFourByFourDitherStrategy());
		orderedDithers.add(new BayerEightByEightDitherStrategy());
		orderedDithers.add(new MagicSquareDitherStrategy());
		orderedDithers.add(new NasikMagicSquareDitherStrategy());
	}

	/**
	 * Any non standard dither strategies (e.g character dither)
	 */
	private final List<DitherStrategy> otherDithers;
	{
		otherDithers = new ArrayList<>();
		otherDithers.add(new CharacterDitherStrategy());
	}
	
	/**
	 * The chosen dither strategy
	 */
	@PreferencesField
	private volatile int selectedDitherStrategy = 0;

	/**
	 * The chosen dither strategy type
	 */
	@PreferencesField
	private volatile String selectedDitherStrategyType = ErrorDiffusionDitherStrategy.class.getName();

	public final static ScalingObject INTERLACED = new ScalingObject(getCaption("scaling_interlace"),
			SpectrumDefaults.SCREEN_WIDTH, SpectrumDefaults.SCREEN_HEIGHT*2);

	/**
	 * Scaling modes available
	 */
	private final List<ScalingObject> scalings;
	{
		scalings = new ArrayList<>();
		scalings.add(new ScalingObject(getCaption("scaling_default"), SpectrumDefaults.SCREEN_WIDTH, SpectrumDefaults.SCREEN_HEIGHT));
		scalings.add(INTERLACED);
		scalings.add(new ScalingObject(getCaption("scaling_none"), -1, -1));
		scalings.add(new ScalingObject(getCaption("scaling_width_prop"), SpectrumDefaults.SCREEN_WIDTH, -1));
		scalings.add(new ScalingObject(getCaption("scaling_height_prop"), -1, SpectrumDefaults.SCREEN_HEIGHT));
	}

	/**
	 * ZX Spectrum scaling mode
	 */
	public final ScalingObject zxScaling = scalings.get(0);	
	
	/**
	 * Currently selected scaling mode
	 */
	@PreferencesField
	public volatile int scaling = 0;

	public static final GigaScreenPaletteStrategy GIGASCREEN_PALETTE_STRATEGY = new GigaScreenPaletteStrategy();

	/**
	 * Pixel colouring strategy - akin to screen modes on the Spectrum
	 * i.e. 2, 15 or 102 colours.
	 */
	private final List<ColourChoiceStrategy> colourModes;
	{
		colourModes = new ArrayList<>();
		colourModes.add(new FullPaletteStrategy());
		colourModes.add(GIGASCREEN_PALETTE_STRATEGY);
		colourModes.add(new MonochromePaletteStrategy());
	}

	/**
	 * Currently selected pixel colouring strategy
	 */
	@PreferencesField
	private volatile int colourMode = 0;

	/**
	 * Attribute favouritism choice - when colours need to be changed to a two
	 * colour attribute block of 8x8 what is favoured?
	 */
	private final List<AttributeStrategy> attributeModes;
	{
		attributeModes = new ArrayList<>();
		attributeModes.add(new FavourHalfBrightAttributeStrategy());
		attributeModes.add(new FavourBrightAttributeStrategy());
		attributeModes.add(new FavourMostPopularAttributeStrategy());
		attributeModes.add(new ForceHalfBrightAttributeStrategy()); 
		attributeModes.add(new ForceBrightAttributeStrategy());
		attributeModes.add(new ForceReducedHalfBrightAttributeStrategy());
	};

	/**
	 * Currently selected attribute favouritism mode
	 */
	@PreferencesField
	private volatile int attributeMode = 0;

	/**
	 * Attribute favouritism choice for Gigascreen. This is similar to the regular
	 * attribute modes but needs to take account for the fact that the extra
	 * colours are created by using two Spectrum screens (e.g. black+white full bright = grey)
	 */
	private final List<GigaScreenAttributeStrategy> gigaScreenAttributeModes;
	{
		gigaScreenAttributeModes = new ArrayList<>();
		gigaScreenAttributeModes.add(new GigaScreenHalfBrightPaletteStrategy());
		gigaScreenAttributeModes.add(new GigaScreenBrightPaletteStrategy());
		gigaScreenAttributeModes.add(new GigaScreenMixedPaletteStrategy());
	};

	private final List<ColourDistanceStrategy> colourDistancesModes;
	{
		colourDistancesModes = new ArrayList<>();
		colourDistancesModes.add(new CompuphaseColourDistanceStrategy());
		colourDistancesModes.add(new ClassicColourDistanceStrategy());
		colourDistancesModes.add(new EuclideanColourDistance());
	}

	/**
	 * Currently selected gigascreen attribute mode
	 */
	@PreferencesField
	private volatile int gigaScreenAttributeMode = 0;

	/**
	 * The chosen giga screen hsb option 
	 */
	@PreferencesField
	private volatile String gigaScreenPaletteOrder = GigaScreenPaletteOrder.Luminosity.name();

	/**
	 * The video converting libraries supported
	 */
	private final List<VideoImportEngine> videoImportEngines;
	{
		videoImportEngines = new ArrayList<>();
		videoImportEngines.add(new HumbleVideoImportEngine());
		
		// VLCJ doesn't work on MacOS.
		if (SystemUtils.IS_OS_MAC_OSX) {
			log.info("Disabling VLCJ import engine - not supported on OSX");
		} else {
			videoImportEngines.add(new VLCVideoImportEngine());
		}
	};

	/**
	 * Currently selected video converter
	 */
	@PreferencesField
	private volatile int videoImportEngine = 0;

	/**
	 * Display frames per second
	 */
	@PreferencesField
	private volatile boolean fpsCounter = false;

	/**
	 * Display frames per second
	 */
	@PreferencesField
	private volatile boolean showWipPreview = true;

	/**
	 * Export image formats available
	 */
	private final String[] imageFormats = new String[] { "png", "jpg" };

	/**
	 * Currently selected image export format
	 */
	@PreferencesField
	private volatile String imageFormat = imageFormats[0];

	/**
	 * Image pre-process contrast setting
	 */
	@PreferencesField
	private volatile float contrast = 1;

	/**
	 * Image pre-process brightness setting
	 */
	@PreferencesField
	private volatile float brightness = 0;

	/**
	 * Image pre-process saturation setting
	 */
	@PreferencesField
	private volatile float saturation = 0;

	/**
	 * Monochrome b/w threshold - determines what value a colour rgb values
	 * must all be below for it to be considered black
	 */
	@PreferencesField
	private volatile int blackThreshold = 128;

	/**
	 * Flag for allowing regular image export
	 */
	@PreferencesField
	private volatile boolean exportImage = true;

	/**
	 * Flag for allowing Spectrum screen file export
	 */
	@PreferencesField
	private volatile boolean exportScreen = false;

	/**
	 * Flag for allowing Spectrum tape (slideshow) file export
	 */
	@PreferencesField
	private volatile boolean exportTape = false;

	/**
	 * Flag to export an anim gif
	 */
	@PreferencesField
	private volatile boolean exportAnimGif = false;

	/**
	 * Flag to export a text dither as a text file
	 */
	@PreferencesField
	private volatile boolean exportText = false;

	/**
	 * The monochrome mode ink colour (spectrum palette index)
	 */
	@PreferencesField
	private volatile int monochromeInkIndex = 0;

	/**
	 * The monochrome mode paper colour (spectrum palette index)
	 */
	@PreferencesField
	private volatile int monochromePaperIndex = 7;

	/**
	 * Intensity for ordered dithering
	 */
	@PreferencesField
	private volatile int orderedDitherIntensity = 1;

	/**
	 * Serpentine dithering mode (image rows traversed 
	 * in alternate directions)
	 */
	@PreferencesField
	private volatile boolean serpentine = false;

	/**
	 * Constrains the error of the error diffusion to 
	 * 8x8 pixels (a single attribute block). Can lead
	 * to a grid pattern effect.
	 */
	@PreferencesField
	private volatile boolean constrainedErrorDiffusion = false;

	/**
	 * Algorithm to compare colour likeness
	 */
	@PreferencesField
	private volatile int colourDistanceStrategy = 0;

	/**
	 * Singleton instance of this class
	 */
	private final static OptionsObject instance;

	static {
		Optional<OptionsObject> tempRef = PreferencesService.load();
		if (tempRef.isPresent()) {
			instance = tempRef.get();
		} else {
			instance = new OptionsObject();
		}
		// Start this early as some engines take a number of seconds to load
		instance.getVideoImportEngine().initVideoImportEngine(Optional.ofNullable(instance.getPathToVideoEngineLibrary()));
		log.info("Options initialised");
	}
	
	/**
	 * Retrieves the only option object instance
	 * 
	 * @return the singleton instance of this object
	 */
	public static OptionsObject getInstance() {
		if (instance == null) {
			log.error("Options instance is null!");
		}
		return instance;
	}
	
	public int getStarts() {
		return starts;
	}

	public void setStarts(int starts) {
		this.starts = starts;
	}

	public void setTurboMode(boolean turbo) {
		this.turboMode = turbo;
	}
	
	public boolean getTurboMode() {
		return turboMode;
	}	
	
	public ErrorDiffusionDitherStrategy[] getErrorDithers() {
		return errorDithers.toArray(new ErrorDiffusionDitherStrategy[0]);
	}

	public ScalingObject[] getScalings() {
		return scalings.toArray(new ScalingObject[0]);
	}

	public ScalingObject getScaling() {
		return scalings.get(scaling);
	}

	public ScalingObject getZXDefaultScaling() {
		return zxScaling;
	}

	public void setScaling(ScalingObject scaling) {
		this.scaling = scalings.indexOf(scaling);
	}

	public float getContrast() {
		return contrast;
	}

	public void setContrast(float contrast) {
		this.contrast = contrast;
	}

	public float getBrightness() {
		return brightness;
	}

	public void setBrightness(float brightness) {
		this.brightness = brightness;
	}

	public float getSaturation() {
		return saturation;
	}

	public void setSaturation(float saturation) {
		this.saturation = saturation;
	}

	public void setFpsCounter(boolean fpsCounter) {
		this.fpsCounter = fpsCounter;
	}

	public void setShowWipPreview(boolean showWipPreview) {
		this.showWipPreview = showWipPreview;
	}

	public String getImageFormat() {
		return imageFormat;
	}

	public void setImageFormat(String imageFormat) {
		this.imageFormat = imageFormat;
	}

	public String[] getImageFormats() {
		return imageFormats;
	}

	public boolean getFpsCounter() {
		return fpsCounter;
	}

	public boolean getShowWipPreview() {
		return showWipPreview;
	}

	public boolean getExportImage() {
		return exportImage;
	}

	public void setExportImage(boolean exportImage) {
		this.exportImage = exportImage;
	}

	public boolean getExportScreen() {
		return exportScreen;
	}

	public void setExportScreen(boolean exportScreen) {
		this.exportScreen = exportScreen;
	}

	public boolean getExportAnimGif() {
		return exportAnimGif;
	}

	public void setExportAnimGif(boolean exportAnimGif) {
		this.exportAnimGif = exportAnimGif;
	}

	public boolean getExportText() {
		return exportText;
	}

	public void setExportText(boolean exportText) {
		this.exportText = exportText;
	}

	public boolean getExportTape() {
		return exportTape;
	}

	public void setExportTape(boolean exportTape) {
		this.exportTape = exportTape;
	}

	public ColourChoiceStrategy getColourMode() {
		return colourModes.get(colourMode);
	}

	public void setColourMode(ColourChoiceStrategy colourMode) {
		this.colourMode = colourModes.indexOf(colourMode);
	}

	public ColourChoiceStrategy[] getColourModes() {
		return colourModes.toArray(new ColourChoiceStrategy[0]);
	}

	public int getMonochromeInkIndex() {
		return monochromeInkIndex;
	}

	public void setMonochromeInkIndex(int monochromeInkIndex) {
		this.monochromeInkIndex = monochromeInkIndex;
	}

	public int getMonochromePaperIndex() {
		return monochromePaperIndex;
	}

	public void setMonochromePaperIndex(int monochromePaperIndex) {
		this.monochromePaperIndex = monochromePaperIndex;
	}

	public int getBlackThreshold() {
		return blackThreshold;
	}

	public void setBlackThreshold(int blackThreshold) {
		this.blackThreshold = blackThreshold;
	}

	public OrderedDitherStrategy[] getOrderedDithers() {
		return orderedDithers.toArray(new OrderedDitherStrategy[0]);
	}

	public DitherStrategy[] getOtherDithers() {
		return otherDithers.toArray(new DitherStrategy[0]);
	}
	
	public DitherStrategy getSelectedDitherStrategy() {
		if (ErrorDiffusionDitherStrategy.class.getName().equals(selectedDitherStrategyType)) {
			return errorDithers.get(selectedDitherStrategy);
		}
		if (OrderedDitherStrategy.class.getName().equals(selectedDitherStrategyType)) {
			return orderedDithers.get(selectedDitherStrategy);
		}
		if (CharacterDitherStrategy.class.getName().equals(selectedDitherStrategyType)) {
			return otherDithers.get(selectedDitherStrategy);
		}
		throw new IllegalStateException("Unknown dither strategy "+selectedDitherStrategy);
	}

	public void setSelectedDitherStrategy(DitherStrategy selectedDitherStrategy) {
		if (errorDithers.contains(selectedDitherStrategy)) {
			this.selectedDitherStrategy = errorDithers.indexOf(selectedDitherStrategy);
			this.selectedDitherStrategyType = ErrorDiffusionDitherStrategy.class.getName();
		}
		if (orderedDithers.contains(selectedDitherStrategy)) {
			this.selectedDitherStrategy = orderedDithers.indexOf(selectedDitherStrategy);
			this.selectedDitherStrategyType = OrderedDitherStrategy.class.getName();
		}
		if (otherDithers.contains(selectedDitherStrategy)) {
			this.selectedDitherStrategy = otherDithers.indexOf(selectedDitherStrategy);
			this.selectedDitherStrategyType = CharacterDitherStrategy.class.getName();
		}
	}

	public int getOrderedDitherIntensity() {
		return orderedDitherIntensity;
	}

	public void setOrderedDitherIntensity(int orderedDitherIntensity) {
		this.orderedDitherIntensity = orderedDitherIntensity;
	}

	public AttributeStrategy getAttributeMode() {
		return attributeModes.get(attributeMode);
	}

	public void setAttributeMode(AttributeStrategy attributeMode) {
		this.attributeMode = attributeModes.indexOf(attributeMode);
	}

	public AttributeStrategy[] getAttributeModes() {
		return attributeModes.toArray(new AttributeStrategy[0]);
	}

	public BasicLoader[] getBasicLoaders() {
		return basicLoaders.toArray(new BasicLoader[0]);
	}

	public BasicLoader getBasicLoader() {
		return basicLoaders.get(basicLoader);
	}

	public void setBasicLoader(BasicLoader basicLoader) {
		this.basicLoader = basicLoaders.indexOf(basicLoader);
	}

	public double getVideoFramesPerSecond() {
		return videoFramesPerSecond;
	}

	public void setVideoFramesPerSecond(double videoFramesPerSecond) {
		this.videoFramesPerSecond = videoFramesPerSecond;
	}

	public int getGifDisplayTimeMillis() {
		return gifDisplayTimeMillis;
	}

	public void setGifDisplayTimeMillis(int gifDisplayTimeMillis) {
		this.gifDisplayTimeMillis = gifDisplayTimeMillis;
	}

	public boolean getSerpentine() {
		return serpentine;
	}

	public void setSerpentine(boolean serpentine) {
		this.serpentine = serpentine;
	}

	public boolean getConstrainedErrorDiffusion() {
		return constrainedErrorDiffusion;
	}

	public void setConstrainedErrorDiffusion(boolean constrainedErrorDiffusion) {
		this.constrainedErrorDiffusion = constrainedErrorDiffusion;
	}

	public VideoImportEngine getVideoImportEngine() {
		return videoImportEngines.get(videoImportEngine);
	}

	public void setVideoImportEngine(VideoImportEngine videoImportEngine) {
		this.videoImportEngine = videoImportEngines.indexOf(videoImportEngine);
	}

	public VideoImportEngine[] getVideoImportEngines() {
		return videoImportEngines.toArray(new VideoImportEngine[0]);
	}

	public String getPathToVideoEngineLibrary() {
		return pathToVideoEngineLibrary;
	}

	public void setPathToVideoEngineLibrary(String path) {
		this.pathToVideoEngineLibrary = path;
	}

	public GigaScreenAttributeStrategy[] getGigaScreenAttributeStrategies() {
		return gigaScreenAttributeModes.toArray(new GigaScreenAttributeStrategy[0]);
	}

	public GigaScreenAttributeStrategy getGigaScreenAttributeStrategy() {
		return gigaScreenAttributeModes.get(gigaScreenAttributeMode);
	}

	public void setGigaScreenAttributeStrategy(GigaScreenAttributeStrategy gigaScreenAttributeStrategy) {
		this.gigaScreenAttributeMode = gigaScreenAttributeModes.indexOf(gigaScreenAttributeStrategy);
	}

	public GigaScreenPaletteOrder[] getGigaScreenPaletteOrders() {
		return GigaScreenPaletteOrder.values();
	}

	public GigaScreenPaletteOrder getGigaScreenPaletteOrder() {
		return GigaScreenPaletteOrder.valueOf(gigaScreenPaletteOrder);
	}

	public void setGigaScreenPaletteOrder(GigaScreenPaletteOrder gigaScreenAttributeOrderingOption) {
		this.gigaScreenPaletteOrder = gigaScreenAttributeOrderingOption.name();
	}

	public ColourDistanceStrategy getColourDistanceMode() {
		return colourDistancesModes.get(colourDistanceStrategy);
	}

	public ColourDistanceStrategy[] getColourDistances() {
		return colourDistancesModes.toArray(new ColourDistanceStrategy[0]);
	}

	public void setColourDistanceStrategy(ColourDistanceStrategy colourDistanceStrategy) {
		this.colourDistanceStrategy =  colourDistancesModes.indexOf(colourDistanceStrategy);
	}
} public class ResultImage {

	private volatile ResultImageType resultImageType;
	private volatile BufferedImage image;

	public ResultImage(ResultImageType resultImageType, BufferedImage image) {
		this.resultImageType = resultImageType;
		this.image = image;
	}

	public ResultImageType getResultImageType() {
		return resultImageType;
	}

	public BufferedImage getImage() {
		return image;
	}

	public void setImage(BufferedImage image) {
		this.image = image;
	}

	public enum ResultImageType {
		FINAL_IMAGE, SUPPORTING_IMAGE
	}

	/**
	 * Utility method to retrieve the final image from an array of result images
	 * 
	 * @param images the array of ResultImages
	 * @return a single ResultImage, if it exists
	 */
	public static Optional<ResultImage> getFinalImage(ResultImage[] images) {
		try {
			for (ResultImage ri : images) {
				if (ResultImageType.FINAL_IMAGE == ri.getResultImageType() && ri.image != null) {
					return Optional.of(ri);
				}
			}
		} catch (NullPointerException npe) {
			// Occurs if settings changed mid run
		}
		return Optional.empty();
	}
} public class ScrConverter {

	/**
	 * SCR images are fixed size (representing Spectrum memory)
	 * - this is the size in bytes
	 */
	private static final int SCR_SIZE = 6912;
	
	/**
	 * GigaScreen SCR size in bytes (two SCRs together)
	 */
	private static final int GIGASCREEN_SCR_SIZE = SCR_SIZE*2;
	
	/**
	 * SCR images are fixed size (representing Spectrum memory)
	 * - this is the size in bits
	 */
	private static final int SCR_BITS_SIZE = SCR_SIZE*8;
	
	/**
	 * Retrieves the Spectrum two ink/paper colour data 
	 * for blocks in the provided image. I.e. a 256x192
	 * image is passed in, this is divided by the Spectrum
	 * colour block size (8x8) and returns a two colour 
	 * ColourData array of 32x24 holding ink and paper.
	 * Note this method EXPECTS PIXELS TO BE IN SPECTRUM
	 * COLOURS ONLY - NO VALIDATION IS DONE!
	 * 
	 * A popularity check is also performed in choosing 
	 * which colour is ink and which is paper - the most
	 * popular is paper (i.e. usually a background)
	 *  
	 * @param img the image to convert
	 * @param imageConverter the image converter to do the conversion
	 * @param screen get SCR data for a given Spectrum screen number (1 if regular, up to 2 if Gigascreen)
	 * @return the colour attributes [width/8][height/8] of the provided image
	 */
	private ColourAttribute[][] getBlockedColourData(BufferedImage img, ImageConverter imageConverter, int screen) {
		int width = img.getWidth()/SpectrumDefaults.ATTRIBUTE_BLOCK_SIZE;
		int height = img.getHeight()/SpectrumDefaults.ATTRIBUTE_BLOCK_SIZE;
		OptionsObject optionsObject = OptionsObject.getInstance();
		ColourAttribute[][] data = new ColourAttribute[width][height];
		for (int y=0; y<height; ++y) {
			for (int x=0; x<width; ++x) {
				data[x][y] = new ColourAttribute();
				int[] block = img.getRGB(x*SpectrumDefaults.ATTRIBUTE_BLOCK_SIZE, y*SpectrumDefaults.ATTRIBUTE_BLOCK_SIZE, SpectrumDefaults.ATTRIBUTE_BLOCK_SIZE, SpectrumDefaults.ATTRIBUTE_BLOCK_SIZE, null, 0, SpectrumDefaults.ATTRIBUTE_BLOCK_SIZE);
				Map<Integer, Integer> check = new HashMap<>();
				for (int i=0; i<block.length; ++i) {
					if (check.containsKey(block[i])) {
						check.put(block[i], check.get(block[i])+1);
					} else {
						check.put(block[i], 1);
					}
				}
				data[x][y].setInkRGB(block[0]);
				data[x][y].setPaperRGB(block[0]);
				int inkCount = 0;
				int paperCount = 0;
				for (int rgb : block) {
					if (rgb != data[x][y].getInkRGB()) {
						data[x][y].setPaperRGB(rgb);
						++paperCount;
					} else {
						++inkCount;
					}
				}
				
				// Apply the GigaScreen palette ordering strategy
				if (imageConverter instanceof GigaScreenConverterImpl) {
					GigaScreenAttributeStrategy gas = optionsObject.getGigaScreenAttributeStrategy();
					if (gas instanceof GigaScreenHalfBrightPaletteStrategy) {
						data[x][y].setBrightSet(false);
					} else if (gas instanceof GigaScreenMixedPaletteStrategy) {
						data[x][y].setBrightSet(screen!=1);
					} else if (gas instanceof GigaScreenBrightPaletteStrategy) {
						data[x][y].setBrightSet(true);
					}
				} else {
					AttributeStrategy attributeStrategy = optionsObject.getAttributeMode();
					// Swap the ink and paper around if ink is more popular (makes it look more aesthetically pleasing when loading)
					if (inkCount > paperCount) {
						int temp = data[x][y].getPaperRGB();
						data[x][y].setPaperRGB(data[x][y].getInkRGB());
						data[x][y].setInkRGB(temp);
					}
					data[x][y].setBrightSet(attributeStrategy.isBrightSet(data[x][y].getPaperRGB(), data[x][y].getInkRGB()));
				}				
			}
		}
		return data;
	}
	
	/**
	 * Convert the original image to a SCR formatted byte array which may be a double
	 * size SCR if the converter is a GigascreenConverter.
	 * 
	 * @param original the original result image
	 * @param imageConverter the image converter that was used to create the result image
	 * @return the SCR byte array
	 */
	public byte[] convert(final ResultImage[] original, ImageConverter imageConverter) {
		if (OptionsObject.getInstance().getColourMode() instanceof GigaScreenPaletteStrategy && !(imageConverter instanceof CharacterConverterImpl)) {
			List<byte[]> data = convertInternal(original, imageConverter);
			
			// Combine both SCR screens into one array suitable for "tap" usage and other
			// non standard SCR viewer programs.
			byte[] combined = new byte[data.get(0).length+data.get(1).length];
			combined = ByteHelper.copyBytes(data.get(0), combined, 0);
			combined = ByteHelper.copyBytes(data.get(1), combined, data.get(0).length);
			return combined;
		}
		return convertInternal(original, imageConverter).get(0);
	}	
	
	/**
	 * Resizes the output image before further processing - it could be dealing
	 * with a source image with dimensions that are not 256x192.
	 * Note that if the images are not already 256x192 then the resulting SCR 
	 * may be sub optimal in that the dither patterns may be broken resulting
	 * in incorrectly dithered colours being placed adjacent to each other.
	 * 
	 * @param resultImage the processed result image to resize
	 * @return new result image with all images resized to 256x192 
	 */
	private ResultImage[] resize(ResultImage[] resultImage) {
		ResultImage[] resized = new ResultImage[resultImage.length];
		for (int i=0; i<resultImage.length; ++i) {
			resized[i] = new ResultImage(resultImage[i].getResultImageType(), ImageHelper.quickScaleImage(resultImage[i].getImage(), SpectrumDefaults.SCREEN_WIDTH, SpectrumDefaults.SCREEN_HEIGHT));
		}
		return resized;
	}
	
	/**
	 * Converts the given image into SCR images
	 * 
	 * @param original the result image
	 * @param imageConverter the image converter used for the result image
	 * @return a list containing the SCR images in little endian byte order. if Gigascreen this will be 2 images, otherwise 1
	 */
	private List<byte[]> convertInternal(final ResultImage[] original, ImageConverter imageConverter) {
		ResultImage[] output = resize(original);
		List<byte[]> scrs = new ArrayList<>();
		for (int screenIndex=0; screenIndex<output.length; screenIndex++) {
			
			// If Gigascreen we don't want to use the final image but the two supporting images
			if (imageConverter instanceof GigaScreenConverterImpl 
					&& ResultImageType.SUPPORTING_IMAGE != output[screenIndex].getResultImageType()) {
				continue;
			}
			BufferedImage image = output[screenIndex].getImage();
			BitSet bitset = new BitSet(SCR_BITS_SIZE);
			int counter = 0;
		
			// Get the palette data
			ColourAttribute[][] colourData = getBlockedColourData(image, imageConverter, screenIndex);
			
			// Set the bit data for our image in the weird Spectrum format
			counter = toScr(image, bitset, counter, colourData);
			
			// Fix the endianness (Java is big endian by default, Spectrum (Z80) 
			// is little endian)
			bitset = ByteHelper.reverseBitSet(bitset);
			
			// Write the palette info into the bitset
			for (int y=0; y<SpectrumDefaults.ROWS; y++) {
				for (int x = 0; x < SpectrumDefaults.COLUMNS; x++) {

					// Convert the Spectrum RGBs to Spectrum palette indexes
					int ink = colourData[x][y].getInkRGB();
					int paper = colourData[x][y].getPaperRGB();
					int specInk = SpectrumDefaults.SPECTRUM_ARGB.get(ink);
					int specPaper = SpectrumDefaults.SPECTRUM_ARGB.get(paper);

					// Set the correct bits (noddy method but does the trick)
					counter = colourToBitSet(specInk, bitset, counter);
					counter = colourToBitSet(specPaper, bitset, counter);

					//Bright
					if (colourData[x][y].isBrightSet()) {
						bitset.set(counter);
					}
					counter++;

					// Flash
					//bitset.set(counter);
					counter++;
				}
			}
			scrs.add(Arrays.copyOf(bitset.toByteArray(), SCR_SIZE));
		}
		return scrs;
	}

	/**
	 * Copies the buffered image input to the given bitset, but only
	 * copies the "ink" values, i.e. those that match the colour data
	 * for the relevant pixel 8x8 range. The data in the bitset is in
	 * correctly formatted scr order.
	 * 
	 * @param image the source image
	 * @param bs the bitset to copy the image into
	 * @param counter the pointer into the bitset
	 * @param colourData the attribute data set for the image
	 * @return the counter's new position
	 */
	private int toScr(BufferedImage image, BitSet bs, int counter, ColourAttribute colourData[][]) {
		for (int thirdChunk=0; thirdChunk < SpectrumDefaults.SCREEN_HEIGHT; thirdChunk+=SpectrumDefaults.SCREEN_HEIGHT_THIRD)
			for (int z=0; z<SpectrumDefaults.ATTRIBUTE_BLOCK_SIZE; z++) { // num horiz lines in 8x8 block
				for (int y=thirdChunk+z; y<thirdChunk+SpectrumDefaults.SCREEN_HEIGHT_THIRD; y+=SpectrumDefaults.ATTRIBUTE_BLOCK_SIZE) { // num lines in third of screen 
					for (int x=0;x<SpectrumDefaults.SCREEN_WIDTH; x++) { //line of horiz pixels
						int xBlock = x/SpectrumDefaults.ATTRIBUTE_BLOCK_SIZE;
						int yBlock = y/SpectrumDefaults.ATTRIBUTE_BLOCK_SIZE;
						
						// If the pixel matches the most popular attribute colour (ink colour) then mark it in the bit set
						if (colourData[xBlock][yBlock].getInkRGB() == image.getRGB(x,y)) {
							bs.set(counter);
						}
						++counter;
					}
				}
		}
		return counter; //Should be 49152;
	}
	
	/**
	 * Shockingly bad method to convert Spectrum colour indexes to
	 * the relevant little endian bits. There is probably a better
	 * way of doing this but I'm tired.
	 * 
	 * "There is 1 byte of attribute for each character cell. Stored left to 
	 * right, top to bottom. Bits 2-0 store the foreground colour, or "ink" 
	 * colour (colour assigned to "on" pixels). Bits 5-3 store the background 
	 * colour or "paper" colour (colour assigned to "off" pixels). Bit 6 is 
	 * bright. If setted, both paper and ink colours are lighter. Bit 7 is 
	 * flash. If setted, the paper and ink colour swap every 640 ms to give 
	 * a kind of flashing character."
	 * 
	 * @param colour the Spectrum colour
	 * @param bs The bitset data to return with Spectrum colour (3 bit chunk) 
	 * @param counter the index into the bitset for this operation
	 * @return the new index for the next operation
	 */
	int colourToBitSet(int colour, BitSet bs, int counter) {
		if (colour == 0) {
			counter +=3;//000
		} else if (colour == 1 || colour == 8) {
			bs.set(counter);
			counter+=3;//100
		} else if (colour == 2 || colour == 9) {
			counter++;//010
			bs.set(counter);
			counter+=2;
		} else if (colour == 3 || colour == 10) {
			bs.set(counter);//110
			counter++;
			bs.set(counter);
			counter+=2;
		} else if (colour == 4 || colour == 11) {
			counter+=2;//001
			bs.set(counter);
			counter++;
		} else if (colour == 5 || colour == 12) {
			bs.set(counter);//101
			counter+=2;
			bs.set(counter);
			counter++;
		} else if (colour == 6 || colour == 13) {
			counter++;//011
			bs.set(counter);
			counter++;
			bs.set(counter);
			counter++;
		} else if (colour == 7 || colour == 14) {
			bs.set(counter);//111
			counter++;
			bs.set(counter);
			counter++;
			bs.set(counter);
			counter++;
		}
		return counter;
	}

	/**
	 * Gets the first of any screens from the provided scrData
	 * 
	 * @param scrData the scr data to get the first screen from
	 * @return a single screen
	 */
	public byte[] getScr1(final byte[] scrData) {
		return Arrays.copyOf(scrData, ScrConverter.SCR_SIZE);
	}
	
	/**
	 * Gets the second of any screens from the provided scrData
	 * 
	 * @param scrData the scr data to get the second screen from
	 * @return a second screen if it exists, otherwise nothing.
	 */
	public Optional<byte[]> getScr2(final byte[] scrData) {
		if (scrData.length < ScrConverter.GIGASCREEN_SCR_SIZE) {
			return Optional.empty();
		}
		return Optional.ofNullable(Arrays.copyOfRange(scrData, ScrConverter.SCR_SIZE, ScrConverter.GIGASCREEN_SCR_SIZE));
	}
} public class TapeConverter {
	
	private final Logger log = LoggerFactory.getLogger(this.getClass());
	
	/**
	 * Outputs an SCR format image into a tap file part
	 * (i.e. a standard data block). 
	 * 
	 * @see <a href="http://www.zx-modules.de/fileformats/tapformat.html">http://www.zx-modules.de/fileformats/tapformat.html</a>
	 * 
	 * @param image the SCR image data to add to the tap file
	 * @return the tap file section containing the Spectrum encoded image
	 */
	public byte[] createTapPart(byte[] image) {
		
		// Standard ROM data block header
		ByteBuffer imageData = ByteBuffer.allocate(2+image.length);
		imageData.order(ByteOrder.LITTLE_ENDIAN);
		imageData.put(0, (byte)255); // 255 indicates ROM loading block - cast just for show
		put(imageData, image, 1); // SCR image data
		imageData.put(6913, getChecksum(imageData.array())); // XOR checksum
		
		// Screen header
		ByteBuffer imageHeader = ByteBuffer.allocate(19);
		imageHeader.order(ByteOrder.LITTLE_ENDIAN);
		imageHeader.put(0, (byte)0); // Indicates ROM header
		imageHeader.put(1, (byte)3); // Indicates BYTE header
		put(imageHeader, "Loading...".getBytes(), 2); // Program loading name
		imageHeader.putShort(12, (short)6912); // Length of data (should be 6912)
		imageHeader.putShort(14, (short)16384); // Start address to import to
		imageHeader.putShort(16, (short)32768); // Unused, must be 32768
		imageHeader.put(18, getChecksum(imageHeader.array())); // XOR checksum
		
		// Copy image header block into data block sub block
		ByteBuffer imageHeaderBlock = ByteBuffer.allocate(2+imageHeader.array().length);
		imageHeaderBlock.order(ByteOrder.LITTLE_ENDIAN);
		imageHeaderBlock.putShort((short)(imageHeader.array().length));
		put(imageHeaderBlock, imageHeader.array(), 2);
		
		// Copy image data into data block sub block
		ByteBuffer imageDataBlock = ByteBuffer.allocate(2+imageData.array().length);
		imageDataBlock.order(ByteOrder.LITTLE_ENDIAN);
		imageDataBlock.putShort((short)(imageData.array().length));
		put(imageDataBlock, imageData.array(), 2);
		
		// Combined the header sub block and image data sub block and return the bytes
		byte[] b = new byte[imageHeaderBlock.array().length+imageDataBlock.array().length];
		b = copyBytes(imageHeaderBlock.array(), b, 0);
		b = copyBytes(imageDataBlock.array(), b, imageHeaderBlock.array().length);
		return b;
	}
	
	/**
	 * Returns a the new tap file bytes containing
	 * a basic SCR loader followed by the SCR images
	 * that have been already converted to TAP parts
	 * 
	 * @param parts the TAP parts (data blocks) contain SCR images
	 * @return the entire tap file as bytes
	 */
	public byte[] createTap(List<byte[]> parts) {
		byte[] loader = createLoader();
		int size = loader.length;
		for (byte[] b: parts) {
			size+=b.length;
		}
		byte[] result = new byte[size];
		ByteHelper.copyBytes(loader, result, 0);
		int index = loader.length;
		for (byte[] b: parts) {
			ByteHelper.copyBytes(b, result, index);
			index += b.length;
		}
		return result;
	}
	
	/**
	 * Creates and returns the TAP loader from the options
	 * selected loader file. N.b this byte data will already
	 * be in little endian order. 
	 * 
	 * @return the byte data contained in the loader file
	 */
	private byte[] createLoader() {
		OptionsObject oo = OptionsObject.getInstance();
		ByteBuffer b = null;
		BufferedInputStream bis = null;
		try {
			InputStream fis = (TapeConverter.class.getResourceAsStream(oo.getBasicLoader().getPath()));
			if (fis == null) {
				fis = new FileInputStream(oo.getBasicLoader().getPath());
			}
			bis = new BufferedInputStream(fis);
			b = ByteBuffer.allocate(4096);
			int data;
			while((data = bis.read()) != -1) {
				b.put((byte)data);
			}	
		} catch(Exception e) {
			log.error("Unable to create loader", e);
		} finally {
			if (bis != null) {
				try {bis.close();}catch(IOException io){log.error("Unable to close load stream");}
			}
		}
		// Create a new array the length of the buffer
		byte[] sized = new byte[b.position()];
		
		// Rewind the buffer and copy the contents to the sized array
		b.rewind();
		b.get(sized);
		return sized;
	}
} public class TextConverter {

	/**
	 * The UTF-8 character to 8x8 pixel map, map.
	 */
	private static Map<String, int[]> charSet = CharacterConverterImpl.charSet;
	
	/**
	 * Iterates over the image in 8x8 blocks and finds the matching character
	 * to replace in a text file.
	 * Note this method expects that the Character Dither has been used however
	 * it will also work without it but the result will be of a lower quality
	 * since the character matching to the original pixels is done here rather
	 * than in the dither step.
	 * 
	 * @param image the result image to convert
	 * @return the UTF-8 text representation
	 */
	public String createText(final BufferedImage image) {
		String text = new String("".getBytes(), Charset.forName("UTF-8"));
		for (int y = 0; y + ATTRIBUTE_BLOCK_SIZE <= image.getHeight(); y += ATTRIBUTE_BLOCK_SIZE) {
			for (int x = 0; x + ATTRIBUTE_BLOCK_SIZE <= image.getWidth() && y + ATTRIBUTE_BLOCK_SIZE <= image.getHeight(); x += ATTRIBUTE_BLOCK_SIZE) {
				int outRgb[] = image.getRGB(x, y, ATTRIBUTE_BLOCK_SIZE, ATTRIBUTE_BLOCK_SIZE, null, 0, ATTRIBUTE_BLOCK_SIZE);
				text += findBestCharacterMatch(outRgb);				
			}
			text+="\n";
		}	
		return text;
	}	
	
	/**
	 * Finds the character string best matching the pixels in the 8x8
	 * pixel sample map
	 * 
	 * @param sample the sample to analyse
	 * @return the UTF-8 character
	 */
	private String findBestCharacterMatch(int[] sample) {
		sample = ColourHelper.getBlackAndWhiteFromMonochrome(sample);
		Map<String, Integer> stats = new HashMap<String, Integer>();
		Set<String> keys = charSet.keySet();
		for (String key : keys) {
			int score = 0;
			int[] character = charSet.get(key);
			for (int i = 0; i < character.length; ++i) {
				if (character[i] == sample[i]) {
					score++;
				}
			}
			stats.put(key, score);
		}
		int bestScore = Integer.MIN_VALUE;
		String chosenKey = null;
		for (String key : keys) {
			int score = stats.get(key);
			if (score > bestScore) {
				bestScore = score;
				chosenKey = key;
			}
		}
		return chosenKey;
	}
} public class GifConverter {

	private Logger log = LoggerFactory.getLogger(this.getClass());

	/**
	 * The number of gif loop iterations (0 = forever)
	 */
	private final int LOOP_COUNT = 0;

	/**
	 * The anim gif's buffered frames
	 */
	private List<BufferedImage> frames;
	
	/**
	 * Byte array required to save the created gif
	 */
	private ByteArrayOutputStream baos;
	
	/**
	 * Create the gif and clear down afterwards.
	 * 
	 * @return the gif byte data
	 * @throws IOException if the output fails
	 */
	public byte[] createGif() throws IOException {
		try {
			ImageOutputStream ios = ImageIO.createImageOutputStream(baos); // N.b. not try with closure because we need to close the stream before returning
			ImageWriter iw = ImageIO.getImageWritersByFormatName("gif").next();	
			iw.setOutput(ios);
			iw.prepareWriteSequence(null);
			int index = 0;
			ImageWriteParam iwp = iw.getDefaultWriteParam();
			String frameDelay = String.valueOf(OptionsObject.getInstance().getGifDisplayTimeMillis() / 10L);
			log.debug("Frames size: {}, Free memory {}", frames.size(), Runtime.getRuntime().freeMemory());
			for (BufferedImage frame : frames) {
				IIOMetadata metadata = iw.getDefaultImageMetadata(new ImageTypeSpecifier(frame), iwp);
				configureMetaData(metadata, frameDelay, index++);
				iw.writeToSequence(new IIOImage(frame, null, metadata), null);
			}
			iw.endWriteSequence();
			ios.close();
			return baos.toByteArray();
		} finally {
			baos = null;
			frames = null;
		}
	}
	
	/**
	 * Adds a single buffered image to the gif being created. 
	 * 
	 * @param image the image to add to the gif
	 */
	public void addFrame(BufferedImage image) {
		frames.add(image);	   
	}

	/**
	 * Create a new gif sequence, initialises in memory storage
	 */
	public void createSequence() {
		baos = new ByteArrayOutputStream();
		
		// BigList is concurrent and has low memory usage.
		frames = new BigList<>();
	}
	
	/**
	 * Configures the per frame metadata
	 * 
	 * @param meta the default meta data
	 * @param delayTime the amount of time a frame is to stay on screen in hundreds of a second (millisecond value/10)
	 * @param imageIndex the index of this frame
	 * @throws IIOInvalidTreeException if the meta data cannot be set
	 */
	private void configureMetaData(IIOMetadata meta, String delayTime, int imageIndex) throws IIOInvalidTreeException {
		String metaFormat = meta.getNativeMetadataFormatName();
		Node root = meta.getAsTree(metaFormat);
		Node child = root.getFirstChild();
		while (child != null) {
			if ("GraphicControlExtension".equals(child.getNodeName())) {
				break;
			}
			child = child.getNextSibling();
		}
		IIOMetadataNode gce = (IIOMetadataNode) child;
		gce.setAttribute("userDelay", "FALSE");
		gce.setAttribute("delayTime", delayTime);
		gce.setAttribute("disposalMethod", "none");

		if (imageIndex == 0) {
			IIOMetadataNode aes = new IIOMetadataNode("ApplicationExtensions");
			IIOMetadataNode ae = new IIOMetadataNode("ApplicationExtension");
			ae.setAttribute("applicationID", "NETSCAPE");
	        ae.setAttribute("authenticationCode", "2.0");
			byte[] uo = new byte[] { 0x1, (byte) (LOOP_COUNT & 0xFF), (byte) ((LOOP_COUNT >> 8) & 0xFF) };
			ae.setUserObject(uo);
			aes.appendChild(ae);
			root.appendChild(aes);
		}
		meta.setFromTree(metaFormat, root);		
	}
} public final class ImageHelper {

	private static final Logger log = LoggerFactory.getLogger(ImageHelper.class);

	/**
	 * Private constructor since we want static use only
	 */
	private ImageHelper() {}

	/**
	 * Scale an image to a given width and height with fast scaling
	 * 
	 * @param img the image to scale
	 * @param width the width to scale to, or -1 to use full Spectrum width
	 * @param height the height to scale to, or -1 to use full Spectrum height
	 * @return the resized image
	 */
	public static BufferedImage quickScaleImage(Image img, int width, int height) {
		return scaleImage(img, width, height, BufferedImage.SCALE_FAST);
	}

	/**
	 * Scale an image to a given width and height with smooth scaling
	 *
	 * @param img the image to scale
	 * @param width the width to scale to, or -1 to use full Spectrum width
	 * @param height the height to scale to, or -1 to use full Spectrum height
	 * @return the resized image
	 */
	public static BufferedImage smoothScaleImage(Image img, int width, int height) {
		return scaleImage(img, width, height, BufferedImage.SCALE_SMOOTH);
	}

	/**
	 * Scale an image to a given width and height with the given scaling mode
	 *
	 * @param img the image to scale
	 * @param width the width to scale to, or -1 to use full Spectrum width
	 * @param height the height to scale to, or -1 to use full Spectrum height
	 * @param scalingMode the BufferedImage scaling option
	 * @return the resized image
	 */
	private static BufferedImage scaleImage(Image img, int width, int height, int scalingMode) {

		int imgWidth = img.getWidth(null);
		int imgHeight = img.getHeight(null);

		// Skip if resize not required or dimensions are -1 (no resize)
		if (imgWidth == width && imgHeight == height) {
			return copyImage(img);
		}

		// TODO: Consider re-write in a formal way, using -1 as undefined is a bit shabby
		if (-1 == width && -1 == height) {
			width = SpectrumDefaults.ATTRIBUTE_BLOCK_SIZE * ((imgWidth / SpectrumDefaults.ATTRIBUTE_BLOCK_SIZE));
			height = SpectrumDefaults.ATTRIBUTE_BLOCK_SIZE * ((imgHeight / SpectrumDefaults.ATTRIBUTE_BLOCK_SIZE));
		}
		log.debug("Original width and height {}x{}, new width and height {}x{}", imgWidth, imgHeight, width, height);
		final Image scaled = img.getScaledInstance(width, height, scalingMode);
		if (-1 == width) {
			width = SpectrumDefaults.SCREEN_WIDTH;
		}
		if (-1 == height) {
			height = SpectrumDefaults.SCREEN_HEIGHT;
		}
		BufferedImage copy = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
		copyImage(scaled, copy);
		return copy;
	}

	/**
	 * Convenience method for copying an image without passing in an image to
	 * copy to.
	 * 
	 * @param source the image to copy
	 * @return the copied image
	 */
	public static BufferedImage copyImage(final Image source) {

		if (source instanceof BufferedImage) {
			BufferedImage bi = ((BufferedImage)source);
			if (BufferedImage.TYPE_INT_RGB == bi.getType()) {	
				ColorModel cm = bi.getColorModel();
				return new BufferedImage(cm, bi.copyData(null), cm.isAlphaPremultiplied(), null);	
			}
		}
		BufferedImage copy = new BufferedImage(source.getWidth(null), source.getHeight(null), BufferedImage.TYPE_INT_RGB);
		copyImage(source, copy);
		return copy;
	}

	/**
	 * Copies the source image to the destination buffered image
	 * 
	 * @param source the source image
	 * @param dest the destination image
	 */
	private static void copyImage(final Image source, BufferedImage dest) {
		Graphics2D g = dest.createGraphics();
		g.drawImage(source, null, null);
		g.dispose();
	}

	/**
	 * Copies the source image to the destination buffered image at the given coordinates
	 * 
	 * Important - does not check the point is valid and the destination image is large enough
	 * 
	 * @param source the source image
	 * @param dest the destination image
	 * @param p the point on the destination image to copy the source image to
	 */
	public static void copyImage(final Image source, BufferedImage dest, Point p) {
		Graphics g = dest.getGraphics();
		g.drawImage(source, p.x, p.y, null);
		g.dispose();
	}


	/**
	 * Prepares the main preview window image prior to rendering (puts the preprocessed image left, processed
	 * image right, does any scaling necessary to fit the screen)
	 *
	 * @param preprocessed the preprocessed image
	 * @param result the result image
	 * @param  fpsString the current processing frame rate
	 * @return the image ready to render in the main window
	 */
	public static BufferedImage prepareMainPreview(BufferedImage preprocessed, BufferedImage result, float fpsString) {
		BufferedImage left = preprocessed;
		BufferedImage right = result;
		int width = result.getWidth();
		int height = result.getHeight();
		boolean needsScaling = false;
		while (imageOutOfScreenBounds(width*2, height)) {
			width /=2;
			height /=2;
			needsScaling = true;
		}
		if (needsScaling) {
			left = ImageHelper.quickScaleImage(preprocessed, width, height);
			right = ImageHelper.smoothScaleImage(result, width, height); // Smooth scale needed in order to maintain dither patterns in preview
		}
		BufferedImage mainPreviewImage = new BufferedImage(width*2, height, result.getType());
		Graphics preBuffer = mainPreviewImage.createGraphics();
		BufferedImage leftResized = left;
		if (OptionsObject.INTERLACED == OptionsObject.getInstance().getScaling()) {
			leftResized = ImageHelper.quickScaleImage(left, width, height);
		}
		preBuffer.drawImage(leftResized, 0, 0, null);
		preBuffer.drawImage(right, width, 0, null);
		if (OptionsObject.getInstance().getFpsCounter()) {
			preBuffer.setColor(Color.WHITE);
			preBuffer.drawString(getCaption("main_fps_overlay") + " " + fpsString, 10, 20);
		}
		preBuffer.dispose();
		return mainPreviewImage;
	}

	/**
	 * Determines if the given image will render outside of the screen bounds
	 *
	 * @param imageWidth the image width
	 * @param imageHeight the image height
	 * @return whether it is outside of the screen
	 */
	private static boolean imageOutOfScreenBounds(int imageWidth, int imageHeight) {
		GraphicsDevice gd = GraphicsEnvironment.getLocalGraphicsEnvironment().getDefaultScreenDevice();
		int width = gd.getDisplayMode().getWidth();
		int height = gd.getDisplayMode().getHeight();
		return (width <= imageWidth || height <= imageHeight);
	}
} public class SaveHelper {
	
	private static final Logger log = LoggerFactory.getLogger(SaveHelper.class);
	
	/**
	 * The default file format and pre-suffix ".zx."
	 */
	public static final String FILE_SUFFIX = ".zx.";
	
	/**
	 * Write an image directly to the specified file deleting
	 * any file with the same name
	 * 
	 * Follows the format [destFolder]/[fileName].zx.[formatName]
	 * 
	 * @param output the image to output
	 * @param destFolder the folder to write the image to
	 * @param fileName the file name of the image
	 * @param formatName the file format suffix
	 * 
	 * @throws IOException thrown if output fails
	 */
	public static void saveImage(final BufferedImage output, final File destFolder, final String fileName, final String formatName) throws IOException {
		log.debug("Dest folder {}, filename {}, formatName {}", destFolder, fileName, formatName);
		String baseFileName = getBaseName(fileName);
		File destFile = new File(destFolder.getAbsolutePath()+"/"+baseFileName+FILE_SUFFIX+formatName);
		deleteFileIfExists(destFile);
		log.debug("Writing image to {} with format {}", destFile, formatName);
		try (OutputStream out = new BufferedOutputStream(new FileOutputStream(destFile))) {
			ImageIO.write(output, formatName, out);
		} catch(IOException io) {
			log.error("Unable to save byte data for file {}", destFile, io);	
			throw io;
		}
	}

	private static String getBaseName(String fileName) {
		if (fileName.contains(".")) {
			return fileName.substring(0, fileName.lastIndexOf("."));
		}
		return fileName;
	}
	
	/**
	 * Saves raw byte data to the given file deleting any
	 * file with the same name
	 * 
	 * @param bytes the byte data to write out
	 * @param file the file to write it to
	 * 
	 * @throws IOException if output fails
	 */
	public static void saveBytes(byte[] bytes, File file) throws IOException {
		deleteFileIfExists(file);
		log.debug("Writing bytes to {}", file);
		try (BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream(file))) {
			out.write(bytes);
		} catch(IOException io) {
			log.error("Unable to save byte data for file {}", file, io);
			throw io;
		}
	}
	
	/**
	 * Deletes the file if it exists
	 * 
	 * @param file the file to delete
	 */
	private static void deleteFileIfExists(File file) {
		if (file.exists()) {
			boolean success = file.delete();
			log.debug("File deletion {} for {}", success, file.getPath());
		}
	}
} public class UiCallback {
	
	/**
	 * Repaints the frame
	 */
	public void repaint() {
		frame.repaint();
	}
		
	/**
	 * Reset's the UI state to enable input
	 */
	public void enableInput() {
		convertButton.setEnabled(true);
		convertButton.setIcon(CONVERT_ICON);
		folder.setEnabled(true);
		outputFolder.setEnabled(true);
	}
		
	/**
	 * Disable the UI state to prevent input
	 */
	public void disableInput() {
		convertButton.setIcon(CONVERT_CANCEL_ICON);
		folder.setEnabled(false);
		outputFolder.setEnabled(false);
	}
		
	/**
	 * Sets a status or error message above the convert button
	 */
	public void resetStatusMessage() {
		statusBox.setText(ImageToZxSpec.DEFAULT_STATUS_MESSAGE);
		statusBox.repaint(1000);
	}
		
	/**
	 * Sets a status or error message above the convert button
	 * @param message the message to display
	 */
	public void setStatusMessage(final String message) {
		statusBox.setText(message);
		statusBox.repaint(1000);
	}

	/**
	 * Resizes the main frame for the supplied image
	 * 
	 * @param image the image to resize the frame for
	 */
	private void resizeFrame(BufferedImage image) {
		Dimension dim = new Dimension(image.getWidth() + frame.getInsets().left + frame.getInsets().right,
				image.getHeight() + statusBox.getHeight() + toolBar.getHeight() + frame.getInsets().top + frame.getInsets().bottom
						+ menubar.getHeight());
		if (!frame.getSize().equals(dim)) {
			frame.setSize(dim);
			frame.setLocationRelativeTo(null);
		}
	}

	/**
	 * Updates the main image ready for the next paint refresh
	 * 
	 * @param previewImage the image to preview
	 */
	public void updateMainImage(BufferedImage previewImage) {
		mainPreviewImage = previewImage;
		resizeFrame(previewImage);
		frame.repaint();
	}

	/**
	 * Processes the popup preview dialog work
	 */
	public void processPopupPreview() {
		preview.setVisible(true);
		workManager.processPreview(new UiCallback(), preview, inFiles);			
	}
		
	/**
	 * Displays a warning message dialog
	 * 
	 * @param title the dialog title
	 * @param message the dialog message
	 */
	public void displayWarning(String title, String message) {
		JOptionPane.showMessageDialog(null, message, title, JOptionPane.WARNING_MESSAGE);
	}
}	 public static String getCaption(String key) {
	String translation = null;
	if (captions != null) {
		try {
			translation = captions.getString(key);
		} catch(MissingResourceException ignore){}
	}
	// We're missing a key's value, use the key as the value :(
	if (translation == null || translation.trim().length()==0) {
		translation=key;
	}
	return translation;
}
import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
import static uk.co.silentsoftware.config.LanguageSupport.getCaption;
import org.magicwerk.brownies.collections.BigList;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import uk.co.silentsoftware.config.OptionsObject;
import uk.co.silentsoftware.core.converters.image.ResultImage;
import uk.co.silentsoftware.core.converters.spectrum.ScrConverter;
import uk.co.silentsoftware.core.converters.spectrum.TapeConverter;
import uk.co.silentsoftware.core.converters.spectrum.TextConverter;
import uk.co.silentsoftware.core.converters.video.GifConverter;
import uk.co.silentsoftware.core.helpers.ImageHelper;
import uk.co.silentsoftware.core.helpers.SaveHelper;
import uk.co.silentsoftware.ui.ImageToZxSpec.UiCallback;
import java.awt.image.BufferedImage;
/* Image to ZX Spec
 * Copyright (C) 2020 Silent Software (Benjamin Brown)
 *
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Affero General Public License
 * as published by the Free Software Foundation, either version 3
 * of the License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
package uk.co.silentsoftware.dispatcher;




/**
 * Manages the work output, i.e. using the work container results it 
 * decides what needs to be output based on the user's chosen options 
 * and calls the relevant converter classes.
 */
class WorkOutputter {

	private final Logger log = LoggerFactory.getLogger(this.getClass());
	
	/**
	 * Text file output for text dithers
	 */
	private static final TextConverter textConverter = new TextConverter();