From b1a46a7fc17bf0ef93bfe641c472e4a9cf4327f0 Mon Sep 17 00:00:00 2001
From: KiraaCorsac <18304-KiraaCorsac@users.noreply.gitgud.io>
Date: Fri, 24 Mar 2023 22:28:57 +0000
Subject: [PATCH] Two-tone hair fixes

---
 devTools/canvasmodel/model.d.ts               |   24 +-
 devTools/canvasmodel/package.json             |    2 +-
 devTools/canvasmodel/renderer.d.ts            |   21 +-
 devTools/canvasmodel/renderer.ts              |  212 +-
 devTools/canvasmodel/yarn.lock                |   14 +-
 game/03-JavaScript/00-libs/renderer.js        | 2225 +++++++++--------
 game/04-Variables/canvasmodel-main.js         |    8 +
 .../overworld-town/loc-shop/hairDressers.twee |    2 +-
 8 files changed, 1393 insertions(+), 1115 deletions(-)

diff --git a/devTools/canvasmodel/model.d.ts b/devTools/canvasmodel/model.d.ts
index d5dc8d1435..0a18671598 100644
--- a/devTools/canvasmodel/model.d.ts
+++ b/devTools/canvasmodel/model.d.ts
@@ -4,8 +4,10 @@ declare interface AnyDict {
 declare interface Dict<T> {
 	[index: string]: T;
 }
+
+type GradientType = 'linear' | 'radial';
 declare interface BlendGradientSpec {
-	gradient: 'linear'|'radial';
+	gradient: GradientType;
 	/**
 	 * * For linear gradient: [x0, y0, x1, y1].
 	 * * For radial gradient: [x0, y0, r0, x1, y1, r1].
@@ -16,15 +18,25 @@ declare interface BlendGradientSpec {
 	 * Pairs of `[offset, color]` or `[color]`.
 	 * Default offsets are for evenly spaced gradient
 	 */
-	colors: ([number,string]|string)[];
+	colors: ([number, string] | string)[];
+}
+
+/**
+ * For applying brightness, contrast and other 'scalar' adjustment gradients
+ */
+declare interface AdjustmentGradientSpec {
+	gradient: GradientType,
+	values: number[],
+	adjustments: ([number, number] | number)[];
 }
+
 declare interface BlendPatternSpec {
 	/**
 	 * Pattern identifier or a specification, sent to pattern provider to get an actual image
 	 */
-	pattern: string|object;
+	pattern: string | object;
 }
-declare type BlendSpec = string|BlendGradientSpec|BlendPatternSpec;
+declare type BlendSpec = string | BlendGradientSpec | BlendPatternSpec;
 
 declare interface CompositeLayerParams {
 	/**
@@ -42,7 +54,7 @@ declare interface CompositeLayerParams {
 	/**
 	 * Blend mode.
 	 */
-	blendMode?: string;
+	blendMode?: GlobalCompositeOperation;
 	/**
 	 * Desaturate the image before processing.
 	 */
@@ -50,7 +62,7 @@ declare interface CompositeLayerParams {
 	/**
 	 * Adjust brightness before processing. -1..+1, 0 is don't change
 	 */
-	brightness?: number;
+	brightness?: number | AdjustmentGradientSpec;
 	/**
 	 * Adjust contrast before processing. >=0, 1 is don't change
 	 */
diff --git a/devTools/canvasmodel/package.json b/devTools/canvasmodel/package.json
index 1b0bb85c9c..d46fac1ecd 100644
--- a/devTools/canvasmodel/package.json
+++ b/devTools/canvasmodel/package.json
@@ -7,6 +7,6 @@
   },
   "devDependencies": {
     "@types/tinycolor2": "^1.4.2",
-    "typescript": "~3.9.3"
+    "typescript": "^5.0.2"
   }
 }
diff --git a/devTools/canvasmodel/renderer.d.ts b/devTools/canvasmodel/renderer.d.ts
index f2be4087b1..4713d4bec0 100644
--- a/devTools/canvasmodel/renderer.d.ts
+++ b/devTools/canvasmodel/renderer.d.ts
@@ -52,7 +52,7 @@ declare namespace Renderer {
     /**
      * Creates a cutout of color in shape of sourceImage
      */
-    export function cutout(sourceImage: CanvasImageSource, color: string, canvas?: CanvasRenderingContext2D): CanvasRenderingContext2D;
+    export function cutout(sourceImage: CanvasImageSource, color: string | CanvasGradient | CanvasPattern, canvas?: CanvasRenderingContext2D): CanvasRenderingContext2D;
     /**
      * Cuts out from base a shape in form of stencil.
      * Modifies and returns base.
@@ -61,12 +61,12 @@ declare namespace Renderer {
     /**
      * Paints sourceImage over cutout of it filled with color.
      */
-    export function composeOverCutout(sourceImage: CanvasImageSource, color: string, blendMode?: string, canvas?: CanvasRenderingContext2D): CanvasRenderingContext2D;
+    export function composeOverCutout(sourceImage: CanvasImageSource, color: string | CanvasGradient | CanvasPattern, blendMode?: GlobalCompositeOperation, canvas?: CanvasRenderingContext2D): CanvasRenderingContext2D;
     /**
      * Repeatedly fill all sub-frames of canvas with same style.
      * (Makes sense with gradient and pattern fills, to keep consistents across all sub-frames)
      */
-    export function fillFrames(fillStyle: string | CanvasGradient | CanvasPattern, canvas: CanvasRenderingContext2D, frameCount: number, frameWidth: number): void;
+    export function fillFrames(fillStyle: string | CanvasGradient | CanvasPattern, canvas: CanvasRenderingContext2D, frameCount: number, frameWidth: number, blendMode: GlobalCompositeOperation): void;
     export let Patterns: Dict<CanvasPattern>;
     /**
      * CanvasPattern generator/provider.
@@ -78,19 +78,23 @@ declare namespace Renderer {
     /**
      * Paints sourceImage over same-sized canvas filled with pattern or gradient
      */
-    export function composeOverSpecialRect(sourceImage: CanvasImageSource, fillStyle: CanvasGradient | CanvasPattern, blendMode: string, frameCount: number, targetCanvas?: CanvasRenderingContext2D): CanvasRenderingContext2D;
+    export function composeOverSpecialRect(sourceImage: CanvasImageSource, fillStyle: CanvasGradient | CanvasPattern, blendMode: GlobalCompositeOperation, frameCount: number, targetCanvas?: CanvasRenderingContext2D): CanvasRenderingContext2D;
+    /**
+     * Paints sourceImage under same-sized canvas filled with pattern or gradient
+     */
+    export function composeUnderSpecialRect(sourceImage: CanvasImageSource, fillStyle: CanvasGradient | CanvasPattern, blendMode: GlobalCompositeOperation, frameCount: number, targetCanvas?: CanvasRenderingContext2D): CanvasRenderingContext2D;
     /**
      * Paints sourceImage over same-sized canvas filled with color
      */
-    export function composeOverRect(sourceImage: CanvasImageSource, color: string, blendMode: string, targetCanvas?: CanvasRenderingContext2D): CanvasRenderingContext2D;
+    export function composeOverRect(sourceImage: CanvasImageSource, color: string, blendMode: GlobalCompositeOperation, targetCanvas?: CanvasRenderingContext2D): CanvasRenderingContext2D;
     /**
      * Paints over sourceImage a cutout of it filled with color.
      */
-    export function composeUnderCutout(sourceImage: CanvasImageSource, color: string, blendMode?: string, canvas?: CanvasRenderingContext2D): CanvasRenderingContext2D;
+    export function composeUnderCutout(sourceImage: CanvasImageSource, color: string, blendMode?: GlobalCompositeOperation, canvas?: CanvasRenderingContext2D): CanvasRenderingContext2D;
     /**
      * Paints over sourceImage a same-sized canvas filled with color
      */
-    export function composeUnderRect(sourceImage: CanvasImageSource, color: string, blendMode?: string, targetCanvas?: CanvasRenderingContext2D): CanvasRenderingContext2D;
+    export function composeUnderRect(sourceImage: CanvasImageSource, color: string, blendMode?: GlobalCompositeOperation, targetCanvas?: CanvasRenderingContext2D): CanvasRenderingContext2D;
     export let ImageCaches: {
         [index: string]: HTMLImageElement;
     };
@@ -100,7 +104,7 @@ declare namespace Renderer {
     /**
      * Switch between compose(Over|Under)(Rect|Cutout)
      */
-    export function compose(composeOver: boolean, doCutout: boolean, sourceImage: CanvasImageSource, color: string, blendMode: string, targetCanvas?: CanvasRenderingContext2D): CanvasRenderingContext2D;
+    export function compose(composeOver: boolean, doCutout: boolean, sourceImage: CanvasImageSource, color: string, blendMode: GlobalCompositeOperation, targetCanvas?: CanvasRenderingContext2D): CanvasRenderingContext2D;
     /**
      * Fills properties in `target` from `source`.
      * If `overwrite` is false, only missing properties are copied.
@@ -112,6 +116,7 @@ declare namespace Renderer {
     export function composeLayersAgain(): void;
     export function desaturateImage(image: CanvasImageSource, resultCanvas?: CanvasRenderingContext2D, doCutout?: boolean): HTMLCanvasElement;
     export function filterImage(image: CanvasImageSource, filter: string, resultCanvas?: CanvasRenderingContext2D): HTMLCanvasElement;
+    export function adjustGradientBrightness(image: CanvasImageSource, frameCount: number, brightness: AdjustmentGradientSpec, resultCanvas?: CanvasRenderingContext2D): HTMLCanvasElement;
     export function adjustBrightness(image: CanvasImageSource, brightness: number, resultCanvas?: CanvasRenderingContext2D, doCutout?: boolean): HTMLCanvasElement;
     export function adjustLevels(image: CanvasImageSource, 
     /**
diff --git a/devTools/canvasmodel/renderer.ts b/devTools/canvasmodel/renderer.ts
index b7be9675fe..bff18cb375 100644
--- a/devTools/canvasmodel/renderer.ts
+++ b/devTools/canvasmodel/renderer.ts
@@ -84,7 +84,7 @@ namespace Renderer {
 		return {
 			desaturate: false,
 			blend: "",
-			blendMode: "",
+			blendMode: "source-over",
 			brightness: 0.0,
 			contrast: 1.0
 		}
@@ -130,9 +130,11 @@ namespace Renderer {
 	/**
 	 * Creates a cutout of color in shape of sourceImage
 	 */
-	export function cutout(sourceImage: CanvasImageSource,
-	                       color: string,
-	                       canvas: CanvasRenderingContext2D = createCanvas(sourceImage.width as number, sourceImage.height as number)): CanvasRenderingContext2D {
+	export function cutout(
+		sourceImage: CanvasImageSource,
+		color: string,
+		canvas: CanvasRenderingContext2D = createCanvas(sourceImage.width as number, sourceImage.height as number)
+	): CanvasRenderingContext2D {
 		let sw = sourceImage.width as number;
 		let sh = sourceImage.height as number;
 		canvas.clearRect(0, 0, sw, sh);
@@ -158,10 +160,11 @@ namespace Renderer {
 	/**
 	 * Paints sourceImage over cutout of it filled with color.
 	 */
-	export function composeOverCutout(sourceImage: CanvasImageSource,
-	                                  color: string,
-	                                  blendMode: string = 'multiply',
-	                                  canvas: CanvasRenderingContext2D = createCanvas(sourceImage.width as number, sourceImage.height as number)
+	export function composeOverCutout(
+		sourceImage: CanvasImageSource,
+		color: string,
+		blendMode: GlobalCompositeOperation = 'multiply',
+		canvas: CanvasRenderingContext2D = createCanvas(sourceImage.width as number, sourceImage.height as number)
 	): CanvasRenderingContext2D {
 		canvas = cutout(sourceImage, color, canvas);
 		// Multiply cutout with original
@@ -176,13 +179,14 @@ namespace Renderer {
 	 * (Makes sense with gradient and pattern fills, to keep consistents across all sub-frames)
 	 */
 	export function fillFrames(
-		fillStyle: string|CanvasGradient|CanvasPattern,
+		fillStyle: string | CanvasGradient | CanvasPattern,
 		canvas: CanvasRenderingContext2D,
 		frameCount: number,
-		frameWidth: number
+		frameWidth: number,
+		blendMode: GlobalCompositeOperation,
 	) {
 		const frameHeight = canvas.canvas.height;
-		canvas.globalCompositeOperation = 'source-over';
+		canvas.globalCompositeOperation = blendMode;
 		canvas.fillStyle = fillStyle;
 		canvas.fillRect(0, 0, frameWidth, frameHeight);
 		if (pixelSize > 1) {
@@ -232,8 +236,8 @@ namespace Renderer {
 				break;
 			case "radial":
 				gradient = globalC2D.createRadialGradient(
-					spec.values[1], spec.values[2], spec.values[3],
-					spec.values[4], spec.values[5], spec.values[6]
+					spec.values[0], spec.values[1], spec.values[2],
+					spec.values[3], spec.values[4], spec.values[5]
 				);
 				break;
 			default:
@@ -260,8 +264,8 @@ namespace Renderer {
 	 */
 	export function composeOverSpecialRect(
 		sourceImage: CanvasImageSource,
-		fillStyle: CanvasGradient|CanvasPattern,
-		blendMode: string,
+		fillStyle: CanvasGradient | CanvasPattern,
+		blendMode: GlobalCompositeOperation,
 		frameCount: number,
 		targetCanvas: CanvasRenderingContext2D = createCanvas(
 			sourceImage.width as number,
@@ -269,21 +273,45 @@ namespace Renderer {
 		)
 	): CanvasRenderingContext2D {
 		let fw = (sourceImage.width as number)/frameCount;
-		fillFrames(fillStyle, targetCanvas, frameCount, fw);
+		fillFrames(fillStyle, targetCanvas, frameCount, fw, 'source-over');
 
 		targetCanvas.globalCompositeOperation = blendMode;
 		targetCanvas.drawImage(sourceImage, 0, 0);
 		return targetCanvas
 	}
 
+	/**
+	 * Paints sourceImage under same-sized canvas filled with pattern or gradient
+	 */
+	export function composeUnderSpecialRect(
+		sourceImage: CanvasImageSource,
+		fillStyle: CanvasGradient | CanvasPattern,
+		blendMode: GlobalCompositeOperation,
+		frameCount: number,
+		targetCanvas: CanvasRenderingContext2D = createCanvas(
+			sourceImage.width as number,
+			sourceImage.height as number
+		)
+	): CanvasRenderingContext2D {
+		let fw = (sourceImage.width as number) / frameCount;
+		const fill = createCanvas(sourceImage.width as number, sourceImage.height as number);
+		fillFrames(fillStyle, fill, frameCount, fw, 'source-over');
+
+		targetCanvas.globalCompositeOperation = 'source-over';
+		targetCanvas.drawImage(sourceImage, 0, 0);
+		targetCanvas.globalCompositeOperation = blendMode;
+		targetCanvas.drawImage(fill.canvas, 0, 0)
+		return targetCanvas;
+	}
+
 	/**
 	 * Paints sourceImage over same-sized canvas filled with color
 	 */
 	export function composeOverRect(sourceImage: CanvasImageSource,
-	                                color: string,
-	                                blendMode: string,
-	                                targetCanvas: CanvasRenderingContext2D = createCanvas(sourceImage.width as number,
-		                                sourceImage.height as number)
+		color: string,
+		blendMode: GlobalCompositeOperation,
+		targetCanvas: CanvasRenderingContext2D = createCanvas(sourceImage.width as number,
+			sourceImage.height as number)
 	): CanvasRenderingContext2D {
 		// Fill with target color
 		targetCanvas.globalCompositeOperation = 'source-over';
@@ -299,10 +327,10 @@ namespace Renderer {
 	 * Paints over sourceImage a cutout of it filled with color.
 	 */
 	export function composeUnderCutout(sourceImage: CanvasImageSource,
-	                                   color: string,
-	                                   blendMode: string = 'multiply',
-	                                   canvas: CanvasRenderingContext2D =
-		                                   createCanvas(sourceImage.width as number, sourceImage.height as number)) {
+		color: string,
+		blendMode: GlobalCompositeOperation = 'multiply',
+		canvas: CanvasRenderingContext2D =
+			createCanvas(sourceImage.width as number, sourceImage.height as number)) {
 		const cut = cutout(sourceImage, color);
 		// Create a copy of sourceImage
 		canvas.globalCompositeOperation = 'source-over';
@@ -318,10 +346,11 @@ namespace Renderer {
 	 * Paints over sourceImage a same-sized canvas filled with color
 	 */
 	export function composeUnderRect(sourceImage: CanvasImageSource,
-	                                   color: string,
-	                                   blendMode: string = 'multiply',
-	                                   targetCanvas: CanvasRenderingContext2D =
-		                                   createCanvas(sourceImage.width as number, sourceImage.height as number)): CanvasRenderingContext2D {
+		color: string,
+		blendMode: GlobalCompositeOperation = 'multiply',
+		targetCanvas: CanvasRenderingContext2D =
+			createCanvas(sourceImage.width as number, sourceImage.height as number)
+	): CanvasRenderingContext2D {
 		let fill = createCanvas(sourceImage.width as number, sourceImage.height as number, color);
 		targetCanvas.globalCompositeOperation = 'source-over';
 		targetCanvas.drawImage(sourceImage, 0, 0);
@@ -345,7 +374,7 @@ namespace Renderer {
 		doCutout: boolean,
 		sourceImage: CanvasImageSource,
 		color: string,
-		blendMode: string,
+		blendMode: GlobalCompositeOperation,
 		targetCanvas: CanvasRenderingContext2D = createCanvas(
 			sourceImage.width as number,
 			sourceImage.height as number)
@@ -374,7 +403,29 @@ namespace Renderer {
 	export function mergeLayerData(target: CompositeLayerSpec, source: CompositeLayerParams, overwrite: boolean = false): CompositeLayerSpec {
 		for (let k of Object.keys(source)) {
 			if (k === 'brightness' && 'brightness' in target) {
-				target.brightness += source.brightness;
+				if (typeof target.brightness === 'object' && typeof source.brightness === 'number') {
+					for (const [adjustmentIndex, adjustment] of target.brightness.adjustments.entries()) {
+						if (typeof adjustment === 'number') {
+							(target.brightness.adjustments[adjustmentIndex] as number) += source.brightness;
+						} else {
+							target.brightness.adjustments[adjustmentIndex][1] += source.brightness;
+						}
+					}
+				} else if (typeof target.brightness === 'number' && typeof source.brightness === 'object') {
+					const brightnessToAdd = target.brightness;
+					target.brightness = { ...source.brightness };
+					for (const [adjustmentIndex, adjustment] of target.brightness.adjustments.entries()) {
+						if (typeof adjustment === 'number') {
+							(target.brightness.adjustments[adjustmentIndex] as number) += brightnessToAdd;
+						} else {
+							target.brightness.adjustments[adjustmentIndex][1] += brightnessToAdd;
+						}
+					}
+				} else if (typeof target.brightness === 'number' && typeof source.brightness === 'number') {
+					target.brightness += source.brightness;
+				} else {
+					throw new Error("Not implemented: cannot merge two gradient brightnesses.")
+				}
 			} else if (k === 'contrast' && 'contrast' in target) {
 				target.contrast *= source.contrast;
 			} else if (overwrite || !(k in target)) {
@@ -404,8 +455,8 @@ namespace Renderer {
 	}
 
 	export function desaturateImage(image: CanvasImageSource,
-	                                resultCanvas?: CanvasRenderingContext2D,
-	                                doCutout: boolean = true): HTMLCanvasElement {
+		resultCanvas?: CanvasRenderingContext2D,
+		doCutout: boolean = true): HTMLCanvasElement {
 		return compose(false, doCutout, image, '#000000', 'saturation', resultCanvas).canvas;
 	}
 
@@ -420,6 +471,76 @@ namespace Renderer {
 		return resultCanvas.canvas;
 	}
 
+	export function adjustGradientBrightness(image: CanvasImageSource,
+		frameCount: number,
+		brightness: AdjustmentGradientSpec,
+		resultCanvas?: CanvasRenderingContext2D): HTMLCanvasElement {
+		if (brightness.adjustments.length !== 2) {
+			throw new Error("Not Implemented: Brightness gradients can have only exactly 2 stops.")
+		}
+
+		const gradientInitializations: { grey: string, neutral: string, offset: number, blendMode: GlobalCompositeOperation }[] = [];
+		for (const [i, adjustment] of brightness.adjustments.entries()) {
+			let brightnessValue: number;
+			let offsetValue: number;
+
+			// [color |[offset, color]]
+			if (typeof adjustment === 'number') {
+				brightnessValue = adjustment;
+				offsetValue = i;
+			} else {
+				brightnessValue = adjustment[1];
+				offsetValue = adjustment[0];
+			}
+
+			// lightnenig or darkening
+			if (brightnessValue > 0) {
+				gradientInitializations.push({
+					grey: gray(brightnessValue),
+					neutral: "#000000",
+					offset: offsetValue,
+					blendMode: 'color-dodge',
+				})
+			} else {
+				gradientInitializations.push({
+					grey: gray(1 + brightnessValue),
+					neutral: "#FFFFFF",
+					offset: offsetValue,
+					blendMode: 'multiply',
+				})
+			}
+		}
+
+		// we needmultiple gradients if we are darkening and lightening at the same time
+		if (gradientInitializations[0].blendMode != gradientInitializations[1].blendMode) {
+			const gradients = [];
+			for (const [i, gradientInit] of gradientInitializations.entries()) {
+				gradients.push(createGradient({
+					...brightness,
+					colors: [
+						[gradientInitializations[0].offset, i === 0 ? gradientInit.grey : gradientInit.neutral],
+						[gradientInitializations[1].offset, i === 0 ? gradientInit.neutral : gradientInit.grey]
+					]
+				}));
+			}
+
+			const firstGradientApplied = composeUnderSpecialRect(image, gradients[0], gradientInitializations[0].blendMode, frameCount);
+			const secondGradientApplied = composeUnderSpecialRect(firstGradientApplied.canvas, gradients[1], gradientInitializations[1].blendMode, frameCount, resultCanvas);
+
+			return secondGradientApplied.canvas;
+		} else {
+			const brightnessGradient = createGradient({
+				...brightness,
+				colors: [
+					[gradientInitializations[0].offset, gradientInitializations[0].grey],
+					[gradientInitializations[1].offset, gradientInitializations[1].grey]
+				]
+			});
+			return composeUnderSpecialRect(image, brightnessGradient, gradientInitializations[0].blendMode, frameCount, resultCanvas).canvas;
+		}
+
+	}
+
 	export function adjustBrightness(image: CanvasImageSource,
 	                                 brightness: number,
 	                                 resultCanvas?: CanvasRenderingContext2D,
@@ -576,11 +697,23 @@ namespace Renderer {
 
 		render(image: CanvasImageSource, layer: CompositeLayer, context: Renderer.RenderPipelineContext): HTMLCanvasElement {
 			context.needsCutout = true;
-			return adjustBrightness(image, layer.brightness, undefined, false);
+			return adjustBrightness(image, layer.brightness as number, undefined, false);
+		}
+	}
+
+	const RenderingStepBrightnessGradient: RenderingStep = {
+		name: "brightness:gradient",
+		condition(layer: CompositeLayer, context: Renderer.RenderPipelineContext): boolean {
+			return layer.brightness && typeof layer.brightness === 'object' && 'gradient' in layer.brightness;
+		},
+
+		render(image: CanvasImageSource, layer: CompositeLayer, context: Renderer.RenderPipelineContext): HTMLCanvasElement {
+			context.needsCutout = true;
+			return adjustGradientBrightness(image, context.rects.subspriteFrameCount, layer.brightness as AdjustmentGradientSpec, undefined);
 		}
 	}
 
-	const RenderingStepContrast:RenderingStep = {
+	const RenderingStepContrast: RenderingStep = {
 		name: "contrast",
 
 		condition(layer: CompositeLayer, context: Renderer.RenderPipelineContext): boolean {
@@ -594,7 +727,7 @@ namespace Renderer {
 
 	}
 
-	const RenderingStepBlendColor:RenderingStep = {
+	const RenderingStepBlendColor: RenderingStep = {
 		name: "blend:color",
 
 		condition(layer: CompositeLayer, context: Renderer.RenderPipelineContext): boolean {
@@ -607,7 +740,7 @@ namespace Renderer {
 		}
 	}
 
-	const RenderingStepBlendGradient:RenderingStep = {
+	const RenderingStepBlendGradient: RenderingStep = {
 		name: "blend:gradient",
 
 		condition(layer: CompositeLayer, context: Renderer.RenderPipelineContext): boolean {
@@ -621,7 +754,7 @@ namespace Renderer {
 		}
 	}
 
-	const RenderingStepBlendPattern:RenderingStep = {
+	const RenderingStepBlendPattern: RenderingStep = {
 		name: "blend:pattern",
 
 		condition(layer: CompositeLayer, context: Renderer.RenderPipelineContext): boolean {
@@ -638,7 +771,7 @@ namespace Renderer {
 		}
 	}
 
-	const RenderingStepMask:RenderingStep = {
+	const RenderingStepMask: RenderingStep = {
 		name: "mask",
 
 		condition(layer: CompositeLayer, context: Renderer.RenderPipelineContext): boolean {
@@ -650,7 +783,7 @@ namespace Renderer {
 		}
 	}
 
-	const RenderingStepCutout:RenderingStep = {
+	const RenderingStepCutout: RenderingStep = {
 		name: "cutout",
 
 		condition(layer: CompositeLayer, context: Renderer.RenderPipelineContext): boolean {
@@ -668,6 +801,7 @@ namespace Renderer {
 	export const RenderingPipeline: RenderingStep[] = [
 		RenderingStepDesaturate,
 		RenderingStepPrefilter,
+		RenderingStepBrightnessGradient,
 		RenderingStepBrightness,
 		RenderingStepContrast,
 		RenderingStepBlendPattern,
diff --git a/devTools/canvasmodel/yarn.lock b/devTools/canvasmodel/yarn.lock
index e8da32808d..2648978978 100644
--- a/devTools/canvasmodel/yarn.lock
+++ b/devTools/canvasmodel/yarn.lock
@@ -3,11 +3,11 @@
 
 
 "@types/tinycolor2@^1.4.2":
-  version "1.4.2"
-  resolved "https://registry.yarnpkg.com/@types/tinycolor2/-/tinycolor2-1.4.2.tgz#721ca5c5d1a2988b4a886e35c2ffc5735b6afbdf"
-  integrity sha512-PeHg/AtdW6aaIO2a+98Xj7rWY4KC1E6yOy7AFknJQ7VXUGNrMlyxDFxJo7HqLtjQms/ZhhQX52mLVW/EX3JGOw==
+  version "1.4.3"
+  resolved "https://registry.yarnpkg.com/@types/tinycolor2/-/tinycolor2-1.4.3.tgz#ed4a0901f954b126e6a914b4839c77462d56e706"
+  integrity sha512-Kf1w9NE5HEgGxCRyIcRXR/ZYtDv0V8FVPtYHwLxl0O+maGX0erE77pQlD0gpP+/KByMZ87mOA79SjifhSB3PjQ==
 
-typescript@~3.9.3:
-  version "3.9.9"
-  resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.9.tgz#e69905c54bc0681d0518bd4d587cc6f2d0b1a674"
-  integrity sha512-kdMjTiekY+z/ubJCATUPlRDl39vXYiMV9iyeMuEuXZh2we6zz80uovNN2WlAxmmdE/Z/YQe+EbOEXB5RHEED3w==
+typescript@^5.0.2:
+  version "5.0.2"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.0.2.tgz#891e1a90c5189d8506af64b9ef929fca99ba1ee5"
+  integrity sha512-wVORMBGO/FAs/++blGNeAVdbNKtIh1rbBL2EyQ1+J9lClJ93KiiKe8PmFIVdXhHcyv44SL9oglmfeSsndo0jRw==
diff --git a/game/03-JavaScript/00-libs/renderer.js b/game/03-JavaScript/00-libs/renderer.js
index 5093b21c8d..ef92a59fa9 100644
--- a/game/03-JavaScript/00-libs/renderer.js
+++ b/game/03-JavaScript/00-libs/renderer.js
@@ -4,1057 +4,1176 @@
  */
 var Renderer;
 (function (Renderer) {
-	const millitime = (typeof performance === 'object' && typeof performance.now === 'function') ?
-		function () {
-			return performance.now();
-		} : function () {
-		return new Date().getTime();
-	};
-	Renderer.DefaultImageLoader = {
-		loadImage(src, layer, successCallback, errorCallback) {
-			const image = new Image();
-			image.onload = () => {
-				successCallback(src, layer, image);
-			};
-			image.onerror = (event) => {
-				errorCallback(src, layer, event);
-			};
-			image.src = src;
-		}
-	};
-	Renderer.ImageLoader = Renderer.DefaultImageLoader;
-	function rendererError(listener, error, context) {
-		if (listener && listener.error) {
-			listener.error(error, context);
-		}
-		else {
-			console.error(error);
-		}
-	}
-	/**
-	 * Last arguments to composeLayers
-	 */
-	Renderer.lastCall = undefined;
-	/**
-	 * Last arguments to animateLayers
-	 */
-	Renderer.lastAnimateCall = undefined;
-	/**
-	 * Last result of animateLayers
-	 */
-	Renderer.lastAnimation = undefined;
-	/**
-	 * Use "pixels" of this size when generating images.
-	 */
-	Renderer.pixelSize = 1;
-	function emptyLayerFilter() {
-		return {
-			desaturate: false,
-			blend: "",
-			blendMode: "",
-			brightness: 0.0,
-			contrast: 1.0
-		};
-	}
-	Renderer.emptyLayerFilter = emptyLayerFilter;
-	/**
-	 * 0 -> "#000000", 0.5 -> "#808080", 1.0 -> "#FFFFFF"
-	 */
-	function gray(value) {
-		value = Math.min(1, Math.max(0, value));
-		value = Math.round(value * 255);
-		let s = value.toString(16);
-		if (value < 16)
-			s = '0' + s;
-		return '#' + s + s + s;
-	}
-	Renderer.gray = gray;
-	function createCanvas(w, h, fill) {
-		let c = document.createElement("canvas");
-		c.width = w;
-		c.height = h;
-		let c2d = c.getContext('2d');
-		if (fill) {
-			c2d.fillStyle = fill;
-			c2d.fillRect(0, 0, w, h);
-		}
-		return c2d;
-	}
-	Renderer.createCanvas = createCanvas;
-	function ensureCanvas(image) {
-		if (image instanceof HTMLCanvasElement) {
-			return image;
-		}
-		let i2 = createCanvas(image.width, image.height);
-		i2.drawImage(image, 0, 0);
-		return i2.canvas;
-	}
-	Renderer.ensureCanvas = ensureCanvas;
-	/**
-	 * Free to use CanvasRenderingContext2D (to create image data, gradients, patterns)
-	 */
-	Renderer.globalC2D = createCanvas(1, 1);
-	/**
-	 * Creates a cutout of color in shape of sourceImage
-	 */
-	function cutout(sourceImage, color, canvas = createCanvas(sourceImage.width, sourceImage.height)) {
-		let sw = sourceImage.width;
-		let sh = sourceImage.height;
-		canvas.clearRect(0, 0, sw, sh);
-		// Fill with target color
-		canvas.globalCompositeOperation = 'source-over';
-		canvas.fillStyle = color;
-		canvas.fillRect(0, 0, sw, sh);
-		return cutoutFrom(canvas, sourceImage);
-	}
-	Renderer.cutout = cutout;
-	/**
-	 * Cuts out from base a shape in form of stencil.
-	 * Modifies and returns base.
-	 */
-	function cutoutFrom(base, stencil) {
-		base.globalCompositeOperation = 'destination-in';
-		base.drawImage(stencil, 0, 0);
-		return base;
-	}
-	Renderer.cutoutFrom = cutoutFrom;
-	/**
-	 * Paints sourceImage over cutout of it filled with color.
-	 */
-	function composeOverCutout(sourceImage, color, blendMode = 'multiply', canvas = createCanvas(sourceImage.width, sourceImage.height)) {
-		canvas = cutout(sourceImage, color, canvas);
-		// Multiply cutout with original
-		canvas.globalCompositeOperation = blendMode;
-		canvas.drawImage(sourceImage, 0, 0);
-		return canvas;
-	}
-	Renderer.composeOverCutout = composeOverCutout;
-	/**
-	 * Repeatedly fill all sub-frames of canvas with same style.
-	 * (Makes sense with gradient and pattern fills, to keep consistents across all sub-frames)
-	 */
-	function fillFrames(fillStyle, canvas, frameCount, frameWidth) {
-		const frameHeight = canvas.canvas.height;
-		canvas.globalCompositeOperation = 'source-over';
-		canvas.fillStyle = fillStyle;
-		canvas.fillRect(0, 0, frameWidth, frameHeight);
-		if (Renderer.pixelSize > 1) {
-			// downscale, redraw on temp canvas, then draw again
-			const tw = Math.floor(frameWidth / Renderer.pixelSize), th = Math.floor(frameHeight / Renderer.pixelSize);
-			const tmpcanvas = createCanvas(tw, th);
-			tmpcanvas.imageSmoothingEnabled = false;
-			canvas.imageSmoothingEnabled = false;
-			tmpcanvas.drawImage(canvas.canvas, 0, 0, frameWidth, frameHeight, 0, 0, tw, th);
-			canvas.drawImage(tmpcanvas.canvas, 0, 0, tw, th, 0, 0, frameWidth, frameHeight);
-		}
-		for (let i = 1; i < frameCount; i++) {
-			canvas.drawImage(canvas.canvas, 0, 0, frameWidth, frameHeight, i * frameWidth, 0, frameWidth, frameHeight);
-		}
-	}
-	Renderer.fillFrames = fillFrames;
-	Renderer.Patterns = {};
-	/**
-	 * CanvasPattern generator/provider.
-	 * Default implementation looks up in the Renderer.Patterns object, can be replaced to accept complex object
-	 * and generate custom pattern.
-	 */
-	Renderer.PatternProvider = (spec) => {
-		if (typeof spec === 'string' && spec in Renderer.Patterns)
-			return Renderer.Patterns[spec];
-		return null;
-	};
-	function createGradient(spec) {
-		let gradient;
-		switch (spec.gradient) {
-			case "linear":
-				gradient = Renderer.globalC2D.createLinearGradient(spec.values[0], spec.values[1], spec.values[2], spec.values[3]);
-				break;
-			case "radial":
-				gradient = Renderer.globalC2D.createRadialGradient(spec.values[0], spec.values[1], spec.values[2], spec.values[3], spec.values[4], spec.values[5]);
-				break;
-			default:
-				throw new Error("Invalid gradient type: " + spec.gradient);
-		}
-		if (spec.colors.length < 2)
-			throw new Error("Invalid gradient stops: " + JSON.stringify(spec.colors));
-		for (let i = 0; i < spec.colors.length; i++) {
-			let stop = spec.colors[i];
-			let offset, color;
-			if (typeof stop === 'string') {
-				color = stop;
-				offset = i / (spec.colors.length - 1);
-			}
-			else {
-				offset = stop[0];
-				color = stop[1];
-			}
-			gradient.addColorStop(offset, color);
-		}
-		return gradient;
-	}
-	Renderer.createGradient = createGradient;
-	/**
-	 * Paints sourceImage over same-sized canvas filled with pattern or gradient
-	 */
-	function composeOverSpecialRect(sourceImage, fillStyle, blendMode, frameCount, targetCanvas = createCanvas(sourceImage.width, sourceImage.height)) {
-		let fw = sourceImage.width / frameCount;
-		fillFrames(fillStyle, targetCanvas, frameCount, fw);
-		targetCanvas.globalCompositeOperation = blendMode;
-		targetCanvas.drawImage(sourceImage, 0, 0);
-		return targetCanvas;
-	}
-	Renderer.composeOverSpecialRect = composeOverSpecialRect;
-	/**
-	 * Paints sourceImage over same-sized canvas filled with color
-	 */
-	function composeOverRect(sourceImage, color, blendMode, targetCanvas = createCanvas(sourceImage.width, sourceImage.height)) {
-		// Fill with target color
-		targetCanvas.globalCompositeOperation = 'source-over';
-		targetCanvas.fillStyle = color;
-		targetCanvas.fillRect(0, 0, sourceImage.width, sourceImage.height);
-		targetCanvas.globalCompositeOperation = blendMode;
-		targetCanvas.drawImage(sourceImage, 0, 0);
-		return targetCanvas;
-	}
-	Renderer.composeOverRect = composeOverRect;
-	/**
-	 * Paints over sourceImage a cutout of it filled with color.
-	 */
-	function composeUnderCutout(sourceImage, color, blendMode = 'multiply', canvas = createCanvas(sourceImage.width, sourceImage.height)) {
-		const cut = cutout(sourceImage, color);
-		// Create a copy of sourceImage
-		canvas.globalCompositeOperation = 'source-over';
-		canvas.drawImage(sourceImage, 0, 0);
-		// Multiply with cutout
-		canvas.globalCompositeOperation = blendMode;
-		canvas.drawImage(cut.canvas, 0, 0);
-		return canvas;
-	}
-	Renderer.composeUnderCutout = composeUnderCutout;
-	/**
-	 * Paints over sourceImage a same-sized canvas filled with color
-	 */
-	function composeUnderRect(sourceImage, color, blendMode = 'multiply', targetCanvas = createCanvas(sourceImage.width, sourceImage.height)) {
-		let fill = createCanvas(sourceImage.width, sourceImage.height, color);
-		targetCanvas.globalCompositeOperation = 'source-over';
-		targetCanvas.drawImage(sourceImage, 0, 0);
-		targetCanvas.globalCompositeOperation = blendMode;
-		targetCanvas.drawImage(fill.canvas, 0, 0);
-		return targetCanvas;
-	}
-	Renderer.composeUnderRect = composeUnderRect;
-	Renderer.ImageCaches = {};
-	Renderer.ImageErrors = {};
-	/**
-	 * Switch between compose(Over|Under)(Rect|Cutout)
-	 */
-	function compose(composeOver, doCutout, sourceImage, color, blendMode, targetCanvas = createCanvas(sourceImage.width, sourceImage.height)) {
-		if (doCutout) {
-			if (composeOver) {
-				return composeOverCutout(sourceImage, color, blendMode, targetCanvas);
-			}
-			else {
-				return composeUnderCutout(sourceImage, color, blendMode, targetCanvas);
-			}
-		}
-		else {
-			if (composeOver) {
-				return composeOverRect(sourceImage, color, blendMode, targetCanvas);
-			}
-			else {
-				return composeUnderRect(sourceImage, color, blendMode, targetCanvas);
-			}
-		}
-	}
-	Renderer.compose = compose;
-	/**
-	 * Fills properties in `target` from `source`.
-	 * If `overwrite` is false, only missing properties are copied.
-	 * In both cases, brightness is added, contrast is multiplied.
-	 * Returns target
-	 */
-	function mergeLayerData(target, source, overwrite = false) {
-		for (let k of Object.keys(source)) {
-			if (k === 'brightness' && 'brightness' in target) {
-				target.brightness += source.brightness;
-			}
-			else if (k === 'contrast' && 'contrast' in target) {
-				target.contrast *= source.contrast;
-			}
-			else if (overwrite || !(k in target)) {
-				target[k] = source[k];
-			}
-		}
-		return target;
-	}
-	Renderer.mergeLayerData = mergeLayerData;
-	function encodeProcessing(spec) {
-		return JSON.stringify({
-			// z, alpha, show, and frames regulate how layer is rendered onto target canvas
-			src: spec.src,
-			blend: spec.blend,
-			blendMode: spec.blendMode,
-			desaturate: spec.desaturate,
-			brightness: spec.brightness,
-			contrast: spec.contrast,
-			prefilter: spec.prefilter,
-			masksrc: spec.masksrc
-		});
-	}
-	Renderer.encodeProcessing = encodeProcessing;
-	function composeLayersAgain() {
-		composeLayers.apply(Renderer, Renderer.lastCall);
-	}
-	Renderer.composeLayersAgain = composeLayersAgain;
-	function desaturateImage(image, resultCanvas, doCutout = true) {
-		return compose(false, doCutout, image, '#000000', 'saturation', resultCanvas).canvas;
-	}
-	Renderer.desaturateImage = desaturateImage;
-	function filterImage(image, filter, resultCanvas) {
-		if (!resultCanvas) {
-			resultCanvas = createCanvas(image.width, image.height);
-		}
-		resultCanvas.filter = filter;
-		resultCanvas.globalCompositeOperation = 'source-over';
-		resultCanvas.drawImage(image, 0, 0);
-		resultCanvas.filter = '';
-		return resultCanvas.canvas;
-	}
-	Renderer.filterImage = filterImage;
-	function adjustBrightness(image, brightness, resultCanvas, doCutout = true) {
-		if (brightness > 0) {
-			const value = gray(brightness);
-			// color-dodge by X% gray adjusts levels 0%-(100-X)% to 0%-100%
-			return compose(false, doCutout, image, value, 'color-dodge', resultCanvas).canvas;
-			// Other option:
-			// screen by X% gray adjust levels 0%-100% to X%-100%
-			// return composeUnderCutout(image, value, 'screen').canvas;
-		}
-		else {
-			// multiply by X% gray adjusts levels 0%-100% to 0%-X%
-			const value = gray(1 + brightness);
-			return compose(false, doCutout, image, value, 'multiply', resultCanvas).canvas;
-			// Other option:
-			// color-burn by X% gray adjusts levels (100-X)%-100% to 0%-100%
-		}
-	}
-	Renderer.adjustBrightness = adjustBrightness;
-	function adjustLevels(image, 
-	/**
-	 * scale factor, 1 - no change, >1 - higher contrast, <1 - lower contrast.
-	 */
-		factor, 
-	/**
-	 * shift, 0 - no change, >0 - brighter, <0 - darker
-	 */
-	shift, resultCanvas) {
-		if (factor >= 1) {
-			/*
-			 color-dodge ( color, X ) = color / (1 - X) ; 0..(1-X) -> 0..1, (1-X) and brighter become white
-			 color-burn ( color, Y ) = 1 - (1 - color) / Y ; (1-Y)..1 -> 0..1, (1-Y) and darker become black
-			 color-burn ( color-dodge ( color, X ), Y ) = (1-1/Y) + color / (Y-X*Y)
-														= shift   + color * factor
-			 given (shift, factor), solving for (X, Y):
-			 X = 1-(1-shift)/factor
-			 Y = 1/(1 - shift)
-			 */
-			const x = 1 - (1 - shift) / factor;
-			const y = 1 / (1 - shift);
-			const c1 = compose(false, false, image, gray(x), 'color-dodge');
-			const c2 = compose(false, false, c1.canvas, gray(y), 'color-burn', resultCanvas);
-			return c2.canvas;
-		}
-		else {
-			/*
-			 multiply ( color, X ) = color * X ; 0..1 -> 0..X
-			 screen ( color, Y ) = 1 - (1 - color) * (1 - Y) ; 0..1 -> Y..1
-			 screen ( multiply ( color, X ), Y ) = 1 - (1 - color * X ) * (1 - Y)
-												 = Y     + color * X*(1-Y)
-												 = shift + color * factor
-			 solving for (X, Y):
-			 Y = shift
-			 X = factor/(1-shift)
-			 */
-			const x = factor / (1 - shift);
-			const y = shift;
-			const c1 = compose(false, false, image, gray(x), 'multiply');
-			const c2 = compose(false, false, c1.canvas, gray(y), 'screen');
-			return c2.canvas;
-		}
-	}
-	Renderer.adjustLevels = adjustLevels;
-	function adjustContrast(image, factor, resultCanvas) {
-		/*
-		 contrast is scale by F with origin at 0.5
-		*/
-		const shift = 0.5 * (1 - factor);
-		return adjustLevels(image, factor, shift, resultCanvas);
-	}
-	Renderer.adjustContrast = adjustContrast;
-	function adjustBrightnessAndContrast(image, brightness, contrast, resultCanvas) {
-		// = adjustContrast (color + brightness, contrast)
-		const shift = brightness * contrast + 0.5 * (1 - contrast);
-		return adjustLevels(image, contrast, shift, resultCanvas);
-	}
-	Renderer.adjustBrightnessAndContrast = adjustBrightnessAndContrast;
-	const RenderingStepDesaturate = {
-		name: "desaturate",
-		condition(layer, context) {
-			return layer.desaturate;
-		},
-		render(image, layer, context) {
-			context.needsCutout = true;
-			return desaturateImage(image, undefined, false);
-		}
-	};
-	const RenderingStepPrefilter = {
-		name: "prefilter",
-		condition(layer, context) {
-			return layer.prefilter && layer.prefilter !== "none";
-		},
-		render(image, layer, context) {
-			return filterImage(image, layer.prefilter);
-		}
-	};
-	const RenderingStepBrightness = {
-		name: "brightness",
-		condition(layer, context) {
-			return typeof layer.brightness === 'number' && layer.brightness !== 0;
-		},
-		render(image, layer, context) {
-			context.needsCutout = true;
-			return adjustBrightness(image, layer.brightness, undefined, false);
-		}
-	};
-	const RenderingStepContrast = {
-		name: "contrast",
-		condition(layer, context) {
-			return typeof layer.contrast === 'number' && layer.contrast !== 1;
-		},
-		render(image, layer, context) {
-			context.needsCutout = true;
-			return adjustContrast(image, layer.contrast, undefined);
-		}
-	};
-	const RenderingStepBlendColor = {
-		name: "blend:color",
-		condition(layer, context) {
-			return layer.blendMode && layer.blend && typeof layer.blend === "string";
-		},
-		render(image, layer, context) {
-			context.needsCutout = true;
-			return composeOverRect(image, layer.blend, layer.blendMode).canvas;
-		}
-	};
-	const RenderingStepBlendGradient = {
-		name: "blend:gradient",
-		condition(layer, context) {
-			return layer.blendMode && layer.blend && typeof layer.blend === 'object' && 'gradient' in layer.blend;
-		},
-		render(image, layer, context) {
-			context.needsCutout = true;
-			let gradient = createGradient(layer.blend);
-			return composeOverSpecialRect(image, gradient, layer.blendMode, context.rects.subspriteFrameCount).canvas;
-		}
-	};
-	const RenderingStepBlendPattern = {
-		name: "blend:pattern",
-		condition(layer, context) {
-			return layer.blendMode && layer.blend && typeof layer.blend === 'object' && 'pattern' in layer.blend;
-		},
-		render(image, layer, context) {
-			context.needsCutout = true;
-			let pattern = Renderer.PatternProvider(layer.blend.pattern);
-			if (!pattern) {
-				return ensureCanvas(image);
-			}
-			return composeOverSpecialRect(image, pattern, layer.blendMode, context.rects.subspriteFrameCount).canvas;
-		}
-	};
-	const RenderingStepMask = {
-		name: "mask",
-		condition(layer, context) {
-			return !!layer.mask;
-		},
-		render(image, layer, context) {
-			return cutoutFrom(ensureCanvas(image).getContext('2d'), layer.mask).canvas;
-		}
-	};
-	const RenderingStepCutout = {
-		name: "cutout",
-		condition(layer, context) {
-			return context.needsCutout;
-		},
-		render(image, layer, context) {
-			return cutoutFrom(ensureCanvas(image).getContext('2d'), layer.image).canvas;
-		}
-	};
-	/**
-	 * Rendering steps used. Order matters!
-	 */
-	Renderer.RenderingPipeline = [
-		RenderingStepDesaturate,
-		RenderingStepPrefilter,
-		RenderingStepBrightness,
-		RenderingStepContrast,
-		RenderingStepBlendPattern,
-		RenderingStepBlendGradient,
-		RenderingStepBlendColor,
-		RenderingStepMask,
-		RenderingStepCutout
-	];
-	function processLayer(layer, rects, listener) {
-		let context = {
-			layer: layer,
-			image: layer.image,
-			needsCutout: false,
-			rects: rects,
-			listener: listener
-		};
-		for (let step of Renderer.RenderingPipeline) {
-			if (!step.condition(context.layer, context))
-				continue;
-			let t0 = millitime();
-			let listener = context.listener;
-			context.image = step.render(context.image, context.layer, context);
-			if (listener && listener.processingStep) {
-				listener.processingStep(context.layer.name, step.name, context.image, millitime() - t0);
-			}
-		}
-		return context.image;
-	}
-	Renderer.processLayer = processLayer;
-	function calcLayerRects(layer, layerImageWidth, targetWidth, targetHeight, frameCount) {
-		const frameWidth = targetWidth / frameCount;
-		const subspriteWidth = layer.width || frameWidth;
-		const subspriteHeight = layer.height || targetHeight;
-		const dx = layer.dx || 0;
-		const dy = layer.dy || 0;
-		const subspriteFrameCount = layerImageWidth / subspriteWidth;
-		return {
-			width: targetWidth,
-			height: targetHeight,
-			frameWidth,
-			frameCount,
-			subspriteWidth,
-			subspriteHeight,
-			subspriteFrameCount,
-			dx,
-			dy
-		};
-	}
-	function composeProcessedLayer(layer, targetCanvas, rects) {
-		const image = layer.cachedImage;
-		targetCanvas.filter = 'none';
-		if (typeof layer.alpha === 'number') {
-			targetCanvas.globalAlpha = layer.alpha;
-		}
-		else {
-			targetCanvas.globalAlpha = 1.0;
-		}
-		const { frameWidth, frameCount, subspriteWidth, subspriteHeight, subspriteFrameCount, dx, dy } = rects;
-		if (rects.subspriteFrameCount === frameCount && !layer.frames) {
-			targetCanvas.drawImage(image, dx, dy);
-		}
-		else {
-			for (let i = 0; i < frameCount; i++) {
-				const imageFrameIndex = Math.min(subspriteFrameCount - 1, layer.frames ? layer.frames[i] : Math.floor(i * subspriteFrameCount / frameCount));
-				targetCanvas.drawImage(image, imageFrameIndex * subspriteWidth, 0, subspriteWidth, subspriteHeight, dx + i * frameWidth, dy, subspriteWidth, subspriteHeight);
-			}
-		}
-	}
-	Renderer.composeProcessedLayer = composeProcessedLayer;
-	function composeLayers(targetCanvas, layerSpecs, frameCount, listener) {
-		Renderer.lastCall = [targetCanvas, layerSpecs, frameCount, listener];
-		const t0 = millitime();
-		// Sort layers by z-index, then array index
-		const layers = layerSpecs
-			.filter(layer => layer.show !== false
-			&& !(typeof layer.alpha === 'number' && layer.alpha <= 0.0))
-			.map((layer, i) => {
-			if (isNaN(layer.z)) {
-				console.error("Layer " + (layer.name || layer.src) + " has z-index NaN");
-				layer.z = 0;
-			}
-			return [layer, i];
-		}) // map to pairs [element, index]
-			.sort((a, b) => {
-			if (a[0].z === b[0].z)
-				return a[1] - b[1];
-			else
-				return a[0].z - b[0].z;
-		})
-			.map(e => e[0]); // unwrap values;
-		if (listener && listener.composeLayers)
-			listener.composeLayers(layers);
-		// Tricky part.
-		// We add <img> elements and hook on their onload event.
-		// When image loads, we put it into layer 'image' property and kick maybeRenderResult
-		// When all images are loaded, we call renderResult
-		let rendered = false;
-		let layersLoaded = 0;
-		function renderResult() {
-			rendered = true;
-			targetCanvas.clearRect(0, 0, targetCanvas.canvas.width, targetCanvas.canvas.height);
-			if (listener && listener.beforeRender) {
-				listener.beforeRender(layers);
-			}
-			const targetWidth = targetCanvas.canvas.width;
-			const targetHeight = targetCanvas.canvas.height;
-			const t1 = millitime();
-			for (const layer of layers) {
-				if (layer.show === false)
-					continue; // Could be disabled due to load error
-				let name = layer.name || layer.src;
-				let image = layer.image;
-				let layerRects = calcLayerRects(layer, image.width, targetWidth, targetHeight, frameCount);
-				let currentProcessing = encodeProcessing(layer);
-				if (layer.cachedProcessing && layer.cachedImage && currentProcessing === layer.cachedProcessing) {
-					if (listener && listener.layerCacheHit) {
-						listener.layerCacheHit(layer);
-					}
-					image = layer.cachedImage;
-				}
-				else {
-					if (listener && listener.layerCacheMiss) {
-						listener.layerCacheMiss(layer);
-					}
-					image = processLayer(layer, layerRects, listener);
-					layer.cachedProcessing = currentProcessing;
-					layer.cachedImage = image;
-				}
-				composeProcessedLayer(layer, targetCanvas, layerRects);
-				if (listener && listener.composition) {
-					listener.composition(name, targetCanvas.canvas);
-				}
-			}
-			if (listener && listener.renderingDone)
-				listener.renderingDone(millitime() - t1);
-		}
-		function maybeRenderResult() {
-			if (rendered)
-				return;
-			for (const layer of layers) {
-				if (layer.show !== false && !layer.image)
-					return;
-				if (layer.masksrc && !layer.mask)
-					return;
-			}
-			if (listener && listener.loadingDone)
-				listener.loadingDone(millitime() - t0, layersLoaded);
-			try {
-				renderResult();
-			}
-			catch (e) {
-				rendererError(listener, e);
-			}
-		}
-		function loadLayerImage(layer) {
-			Renderer.ImageLoader.loadImage(layer.src, layer, (src, layer, image) => {
-				layersLoaded++;
-				if (listener && listener.loaded) {
-					listener.loaded(layer.name || 'unnamed', src);
-				}
-				layer.image = image;
-				layer.imageSrc = src;
-				Renderer.ImageCaches[src] = image;
-				maybeRenderResult();
-			}, (src, layer, error) => {
-				// Mark this src as erroneous to avoid blinking due to reload attempts
-				Renderer.ImageErrors[src] = true;
-				if (listener && listener.loadError) {
-					listener.loadError(layer.name || 'unnamed', src);
-				}
-				else {
-					console.error('Failed to load image ' + src + (layer.name ? ' for layer ' + layer.name : ''));
-				}
-				layer.show = false;
-				maybeRenderResult();
-			});
-		}
-		function loadLayerMask(layer) {
-			Renderer.ImageLoader.loadImage(layer.masksrc, layer, (src, layer, image) => {
-				layersLoaded++;
-				if (listener && listener.loaded) {
-					listener.loaded(layer.name || 'unnamed', src);
-				}
-				layer.mask = image;
-				layer.cachedMaskSrc = src;
-				Renderer.ImageCaches[src] = image;
-				maybeRenderResult();
-			}, (src, layer, error) => {
-				// Mark this src as erroneous to avoid blinking due to reload attempts
-				Renderer.ImageErrors[src] = true;
-				if (listener && listener.loadError) {
-					listener.loadError(layer.name || 'unnamed', src);
-				}
-				else {
-					console.error('Failed to load mask ' + src + (layer.name ? ' for layer ' + layer.name : ''));
-				}
-				delete layer.masksrc;
-				maybeRenderResult();
-			});
-		}
-		for (const layer of layers) {
-			let needImage = true;
-			if (layer.image) {
-				if (layer.imageSrc === layer.src) {
-					needImage = false;
-				}
-				else {
-					// Layer was loaded in previous render, but then its src was changed - purge cache
-					delete layer.image;
-					delete layer.imageSrc;
-				}
-			}
-			if (needImage) {
-				if (Renderer.ImageErrors[layer.src]) {
-					layer.show = false;
-					continue;
-				}
-				else if (layer.src in Renderer.ImageCaches) {
-					layer.image = Renderer.ImageCaches[layer.src];
-					layer.imageSrc = layer.src;
-				}
-				else {
-					loadLayerImage(layer);
-				}
-			}
-			let needMask = !!layer.masksrc;
-			if (layer.mask) {
-				if (layer.cachedMaskSrc === layer.masksrc) {
-					needMask = false;
-				}
-				else {
-					// Layer mask was loaded in previous render, but then its masksrc was changed - purge cache
-					delete layer.mask;
-					delete layer.cachedMaskSrc;
-				}
-			}
-			if (needMask) {
-				if (Renderer.ImageErrors[layer.masksrc]) {
-					delete layer.masksrc;
-				}
-				else if (layer.masksrc in Renderer.ImageCaches) {
-					layer.mask = Renderer.ImageCaches[layer.masksrc];
-					layer.cachedMaskSrc = layer.masksrc;
-				}
-				else {
-					loadLayerMask(layer);
-				}
-			}
-		}
-		maybeRenderResult();
-	}
-	Renderer.composeLayers = composeLayers;
-	function invalidateLayerCaches(layers) {
-		for (let layer of layers) {
-			delete layer.image;
-			delete layer.imageSrc;
-			delete layer.mask;
-			delete layer.cachedMaskSrc;
-			delete layer.cachedImage;
-			delete layer.cachedProcessing;
-		}
-	}
-	Renderer.invalidateLayerCaches = invalidateLayerCaches;
-	function animateLayersAgain() {
-		return animateLayers.apply(Renderer, Renderer.lastAnimateCall);
-	}
-	Renderer.animateLayersAgain = animateLayersAgain;
-	const animatingCanvases = new WeakMap();
-	Renderer.Animations = {};
-	/**
-	 * Animation spec provider; default implementation is look up in Renderer.Animations by layer's `animation` property.
-	 *
-	 * Can be overridden to auto-generate animations, for example.
-	 */
-	Renderer.AnimationProvider = layer => Renderer.Animations[layer.animation];
-	/**
-	 * Animatable properties of KeyframeSpec and CompositeLayer
-	 */
-	Renderer.AnimatableProps = ["alpha", "show", "blend", "brightness", "contrast", "dx", "dy"];
-	function animateLayers(targetCanvas, layerSpecs, listener, autoStop = true) {
-		Renderer.lastAnimateCall = [targetCanvas, layerSpecs, listener, autoStop];
-		const keyframeCaches = {};
-		function invalidateCaches() {
-			for (let key in keyframeCaches)
-				delete keyframeCaches[key];
-		}
-		let schedule = {};
-		// this mess should become a class already
-		const animatingCanvas = {
-			target: targetCanvas,
-			keyframeCaches: keyframeCaches,
-			animations: [],
-			playing: false,
-			busy: false,
-			start() {
-				if (this.playing)
-					this.stop();
-				this.playing = true;
-				// stop previous animation on this targetCanvas, if present
-				let oldAnimation = animatingCanvases.get(targetCanvas);
-				if (oldAnimation != null) {
-					oldAnimation.stop();
-				}
-				animatingCanvases.set(targetCanvas, this);
-				let usedAnimations = {};
-				for (let layer of layerSpecs) {
-					if (!layer.src || layer.show === false)
-						continue;
-					if (layer.animation) {
-						let spec = Renderer.AnimationProvider(layer);
-						if (!spec) {
-							console.error("Layer '" + (layer.name || layer.src) + "' animation '" + layer.animation + "' not found");
-							continue;
-						}
-						let complex = false;
-						if ('frames' in spec) {
-							let frames = spec.frames, duration = spec.duration;
-							spec = {
-								keyframes: []
-							};
-							for (let i = 0; i < frames; i++) {
-								spec.keyframes.push({ frame: i, duration: duration });
-							}
-						}
-						else {
-							for (let kf of spec.keyframes) {
-								for (let ap of Renderer.AnimatableProps) {
-									if (ap in kf) {
-										complex = true;
-										break;
-									}
-								}
-								if (complex)
-									break;
-							}
-						}
-						let animation = usedAnimations[layer.animation];
-						if (!animation) {
-							animation = usedAnimations[layer.animation] = {
-								name: layer.animation,
-								complex: complex,
-								spec: spec,
-								timeoutId: 0,
-								keyframeIndex: 0,
-								keyframe: spec.keyframes[0],
-								layers: [],
-								time: 0
-							};
-						}
-						animation.layers.push(layer);
-						applyKeyframe(animation.keyframe, layer);
-					}
-					else {
-						layer.frames = [0];
-					}
-				}
-				this.animations = Object.values(usedAnimations);
-				for (let animation of this.animations) {
-					scheduleNextKeyframe(animation);
-					if (listener && listener.keyframe)
-						listener.keyframe(animation.name, animation.keyframeIndex, animation.keyframe);
-				}
-				compose().catch((e) => { if (e)
-					console.error(e); });
-			},
-			stop() {
-				if (!this.playing)
-					return;
-				this.playing = false;
-				animatingCanvases.delete(targetCanvas);
-				for (let info of this.animations) {
-					if (info.timeoutId)
-						clearTimeout(info.timeoutId);
-				}
-				schedule = {};
-				this.animations.splice(0);
-				invalidateCaches();
-				if (listener && listener.animationStop)
-					listener.animationStop();
-			},
-			invalidateCaches,
-			time: 0,
-			redraw() {
-				compose().catch((e) => { if (e)
-					console.error(e); });
-			}
-		};
-		function genAnimationSpec() {
-			let j = {};
-			for (let animation of animatingCanvas.animations) {
-				if (animation.complex) {
-					j[animation.name] = animation.keyframeIndex;
-				}
-				else {
-					j[animation.name] = animation.keyframe.frame;
-				}
-			}
-			return JSON.stringify(j);
-		}
-		function scheduleNextKeyframe(animation) {
-			if (animation.keyframe.duration <= 0)
-				return;
-			let t1 = animation.time + animation.keyframe.duration;
-			let tasks = schedule[t1];
-			if (!tasks) {
-				schedule[t1] = tasks = [];
-				animation.timeoutId = window.setTimeout(() => {
-					try {
-						delete schedule[t1];
-						animatingCanvas.time = Math.max(t1, animatingCanvas.time);
-						for (let task of tasks)
-							task();
-						compose().catch((e) => { if (e)
-							console.error(e); });
-					}
-					catch (e) {
-						rendererError(listener, e);
-					}
-				}, animation.keyframe.duration);
-			}
-			else {
-				animation.timeoutId = 0;
-			}
-			tasks.push(() => {
-				animation.time = t1;
-				nextKeyframe(animation);
-			});
-		}
-		function applyKeyframe(keyframe, layer) {
-			layer.frames = [keyframe.frame];
-			for (let ap of Renderer.AnimatableProps) {
-				if (ap in keyframe)
-					layer[ap] = keyframe[ap];
-			}
-		}
-		function nextKeyframe(animation) {
-			let keyframes = animation.spec.keyframes;
-			animation.keyframeIndex = (animation.keyframeIndex + 1) % keyframes.length;
-			animation.keyframe = keyframes[animation.keyframeIndex];
-			for (let layer of animation.layers) {
-				applyKeyframe(animation.keyframe, layer);
-			}
-			scheduleNextKeyframe(animation);
-			if (listener && listener.keyframe)
-				listener.keyframe(animation.name, animation.keyframeIndex, animation.keyframe);
-		}
-		function stopCheck() {
-			if (autoStop && animatingCanvas.time > 0 && !(document.body.contains(targetCanvas.canvas))) {
-				/* the canvas was removed from DOM. we exclude frame 0 because it might not yet be added */
-				animatingCanvas.stop();
-				return true;
-			}
-			return false;
-		}
-		function compose() {
-			if (stopCheck() || animatingCanvas.busy) {
-				return Promise.reject();
-			}
-			animatingCanvas.busy = true;
-			return new Promise((resolve, reject) => {
-				requestAnimationFrame(() => {
-					animatingCanvas.busy = false;
-					try {
-						doCompose0();
-						resolve();
-					}
-					catch (e) {
-						rendererError(listener, e);
-						reject(e);
-					}
-				});
-			});
-			function doCompose0() {
-				let spec = genAnimationSpec();
-				let cachedCanvas = keyframeCaches[spec];
-				if (cachedCanvas) {
-					const t0 = millitime();
-					targetCanvas.clearRect(0, 0, targetCanvas.canvas.width, targetCanvas.canvas.height);
-					targetCanvas.globalAlpha = 1.0;
-					targetCanvas.drawImage(cachedCanvas.canvas, 0, 0);
-					if (listener && listener.keyframeRender) {
-						listener.keyframeRender(spec, true, millitime() - t0);
-					}
-				}
-				else {
-					if (listener && listener.keyframeRender) {
-						listener.keyframeRender(spec, false, 0);
-					}
-					const myListener = Object.assign({}, listener, {
-						renderingDone(time) {
-							let canvas = createCanvas(targetCanvas.canvas.width, targetCanvas.canvas.height);
-							canvas.drawImage(targetCanvas.canvas, 0, 0);
-							keyframeCaches[genAnimationSpec()] = canvas;
-							if (listener && listener.renderingDone)
-								listener.renderingDone.apply(listener, arguments);
-						}
-					});
-					try {
-						composeLayers(targetCanvas, layerSpecs, 1, myListener);
-					}
-					catch (e) {
-						animatingCanvas.stop();
-						throw e;
-					}
-				}
-			}
-		}
-		animatingCanvas.start();
-		return (Renderer.lastAnimation = animatingCanvas);
-	}
-	Renderer.animateLayers = animateLayers;
-	/**
-	 * Linear interpolation.
-	 *
-	 * f(0) = min,
-	 * f(1) = max.
-	 */
-	function lint(value, min, max, allowOverflow = false) {
-		if (!allowOverflow)
-			value = Math.min(1, Math.max(0, value));
-		return value * (max - min) + min;
-	}
-	Renderer.lint = lint;
-	function lintArray(value, mins, maxes, allowOverflow = false) {
-		return mins.map((min, i) => lint(value, min, maxes[i], allowOverflow));
-	}
-	Renderer.lintArray = lintArray;
-	function lintStaged(value, points) {
-		value = Math.min(1, Math.max(0, value));
-		const n = points.length - 1;
-		let i = (value * n) | 0;
-		if (i === n)
-			i = n - 1;
-		return lint(value * n - i, points[i], points[i + 1]);
-	}
-	Renderer.lintStaged = lintStaged;
-	function lintRgb(value, min, max) {
-		min = tinycolor(min).toRgb();
-		max = tinycolor(max).toRgb();
-		return tinycolor({
-			r: lint(value, min.r, max.r),
-			g: lint(value, min.g, max.g),
-			b: lint(value, min.b, max.b)
-		});
-	}
-	Renderer.lintRgb = lintRgb;
-	function lintRgbStaged(value, points) {
-		value = Math.min(1, Math.max(0, value));
-		const n = points.length - 1;
-		let i = (value * n) | 0;
-		if (i === n)
-			i = n - 1;
-		return lintRgb(value * n - i, points[i], points[i + 1]);
-	}
-	Renderer.lintRgbStaged = lintRgbStaged;
-	window.Renderer = Renderer;
-	// Expose library functions needed by model evaluation, to global ns
-	window.lint = Renderer.lint;
-	window.lintArray = Renderer.lintArray;
-	window.lintStaged = Renderer.lintStaged;
-	window.lintRgb = Renderer.lintRgb;
-	window.lintRgbStaged = Renderer.lintRgbStaged;
+    const millitime = (typeof performance === 'object' && typeof performance.now === 'function') ?
+        function () {
+            return performance.now();
+        } : function () {
+            return new Date().getTime();
+        };
+    Renderer.DefaultImageLoader = {
+        loadImage(src, layer, successCallback, errorCallback) {
+            const image = new Image();
+            image.onload = () => {
+                successCallback(src, layer, image);
+            };
+            image.onerror = (event) => {
+                errorCallback(src, layer, event);
+            };
+            image.src = src;
+        }
+    };
+    Renderer.ImageLoader = Renderer.DefaultImageLoader;
+    function rendererError(listener, error, context) {
+        if (listener && listener.error) {
+            listener.error(error, context);
+        }
+        else {
+            console.error(error);
+        }
+    }
+    /**
+     * Last arguments to composeLayers
+     */
+    Renderer.lastCall = undefined;
+    /**
+     * Last arguments to animateLayers
+     */
+    Renderer.lastAnimateCall = undefined;
+    /**
+     * Last result of animateLayers
+     */
+    Renderer.lastAnimation = undefined;
+    /**
+     * Use "pixels" of this size when generating images.
+     */
+    Renderer.pixelSize = 1;
+    function emptyLayerFilter() {
+        return {
+            desaturate: false,
+            blend: "",
+            blendMode: "source-over",
+            brightness: 0.0,
+            contrast: 1.0
+        };
+    }
+    Renderer.emptyLayerFilter = emptyLayerFilter;
+    /**
+     * 0 -> "#000000", 0.5 -> "#808080", 1.0 -> "#FFFFFF"
+     */
+    function gray(value) {
+        value = Math.min(1, Math.max(0, value));
+        value = Math.round(value * 255);
+        let s = value.toString(16);
+        if (value < 16)
+            s = '0' + s;
+        return '#' + s + s + s;
+    }
+    Renderer.gray = gray;
+    function createCanvas(w, h, fill) {
+        let c = document.createElement("canvas");
+        c.width = w;
+        c.height = h;
+        let c2d = c.getContext('2d');
+        if (fill) {
+            c2d.fillStyle = fill;
+            c2d.fillRect(0, 0, w, h);
+        }
+        return c2d;
+    }
+    Renderer.createCanvas = createCanvas;
+    function ensureCanvas(image) {
+        if (image instanceof HTMLCanvasElement) {
+            return image;
+        }
+        let i2 = createCanvas(image.width, image.height);
+        i2.drawImage(image, 0, 0);
+        return i2.canvas;
+    }
+    Renderer.ensureCanvas = ensureCanvas;
+    /**
+     * Free to use CanvasRenderingContext2D (to create image data, gradients, patterns)
+     */
+    Renderer.globalC2D = createCanvas(1, 1);
+    /**
+     * Creates a cutout of color in shape of sourceImage
+     */
+    function cutout(sourceImage, color, canvas = createCanvas(sourceImage.width, sourceImage.height)) {
+        let sw = sourceImage.width;
+        let sh = sourceImage.height;
+        canvas.clearRect(0, 0, sw, sh);
+        // Fill with target color
+        canvas.globalCompositeOperation = 'source-over';
+        canvas.fillStyle = color;
+        canvas.fillRect(0, 0, sw, sh);
+        return cutoutFrom(canvas, sourceImage);
+    }
+    Renderer.cutout = cutout;
+    /**
+     * Cuts out from base a shape in form of stencil.
+     * Modifies and returns base.
+     */
+    function cutoutFrom(base, stencil) {
+        base.globalCompositeOperation = 'destination-in';
+        base.drawImage(stencil, 0, 0);
+        return base;
+    }
+    Renderer.cutoutFrom = cutoutFrom;
+    /**
+     * Paints sourceImage over cutout of it filled with color.
+     */
+    function composeOverCutout(sourceImage, color, blendMode = 'multiply', canvas = createCanvas(sourceImage.width, sourceImage.height)) {
+        canvas = cutout(sourceImage, color, canvas);
+        // Multiply cutout with original
+        canvas.globalCompositeOperation = blendMode;
+        canvas.drawImage(sourceImage, 0, 0);
+        return canvas;
+    }
+    Renderer.composeOverCutout = composeOverCutout;
+    /**
+     * Repeatedly fill all sub-frames of canvas with same style.
+     * (Makes sense with gradient and pattern fills, to keep consistents across all sub-frames)
+     */
+    function fillFrames(fillStyle, canvas, frameCount, frameWidth, blendMode) {
+        const frameHeight = canvas.canvas.height;
+        canvas.globalCompositeOperation = blendMode;
+        canvas.fillStyle = fillStyle;
+        canvas.fillRect(0, 0, frameWidth, frameHeight);
+        if (Renderer.pixelSize > 1) {
+            // downscale, redraw on temp canvas, then draw again
+            const tw = Math.floor(frameWidth / Renderer.pixelSize), th = Math.floor(frameHeight / Renderer.pixelSize);
+            const tmpcanvas = createCanvas(tw, th);
+            tmpcanvas.imageSmoothingEnabled = false;
+            canvas.imageSmoothingEnabled = false;
+            tmpcanvas.drawImage(canvas.canvas, 0, 0, frameWidth, frameHeight, 0, 0, tw, th);
+            canvas.drawImage(tmpcanvas.canvas, 0, 0, tw, th, 0, 0, frameWidth, frameHeight);
+        }
+        for (let i = 1; i < frameCount; i++) {
+            canvas.drawImage(canvas.canvas, 0, 0, frameWidth, frameHeight, i * frameWidth, 0, frameWidth, frameHeight);
+        }
+    }
+    Renderer.fillFrames = fillFrames;
+    Renderer.Patterns = {};
+    /**
+     * CanvasPattern generator/provider.
+     * Default implementation looks up in the Renderer.Patterns object, can be replaced to accept complex object
+     * and generate custom pattern.
+     */
+    Renderer.PatternProvider = (spec) => {
+        if (typeof spec === 'string' && spec in Renderer.Patterns)
+            return Renderer.Patterns[spec];
+        return null;
+    };
+    function createGradient(spec) {
+        let gradient;
+        switch (spec.gradient) {
+            case "linear":
+                gradient = Renderer.globalC2D.createLinearGradient(spec.values[0], spec.values[1], spec.values[2], spec.values[3]);
+                break;
+            case "radial":
+                gradient = Renderer.globalC2D.createRadialGradient(spec.values[0], spec.values[1], spec.values[2], spec.values[3], spec.values[4], spec.values[5]);
+                break;
+            default:
+                throw new Error("Invalid gradient type: " + spec.gradient);
+        }
+        if (spec.colors.length < 2)
+            throw new Error("Invalid gradient stops: " + JSON.stringify(spec.colors));
+        for (let i = 0; i < spec.colors.length; i++) {
+            let stop = spec.colors[i];
+            let offset, color;
+            if (typeof stop === 'string') {
+                color = stop;
+                offset = i / (spec.colors.length - 1);
+            }
+            else {
+                offset = stop[0];
+                color = stop[1];
+            }
+            gradient.addColorStop(offset, color);
+        }
+        return gradient;
+    }
+    Renderer.createGradient = createGradient;
+    /**
+     * Paints sourceImage over same-sized canvas filled with pattern or gradient
+     */
+    function composeOverSpecialRect(sourceImage, fillStyle, blendMode, frameCount, targetCanvas = createCanvas(sourceImage.width, sourceImage.height)) {
+        let fw = sourceImage.width / frameCount;
+        fillFrames(fillStyle, targetCanvas, frameCount, fw, 'source-over');
+        targetCanvas.globalCompositeOperation = blendMode;
+        targetCanvas.drawImage(sourceImage, 0, 0);
+        return targetCanvas;
+    }
+    Renderer.composeOverSpecialRect = composeOverSpecialRect;
+    /**
+     * Paints sourceImage under same-sized canvas filled with pattern or gradient
+     */
+    function composeUnderSpecialRect(sourceImage, fillStyle, blendMode, frameCount, targetCanvas = createCanvas(sourceImage.width, sourceImage.height)) {
+        let fw = sourceImage.width / frameCount;
+        const fill = createCanvas(sourceImage.width, sourceImage.height);
+        fillFrames(fillStyle, fill, frameCount, fw, 'source-over');
+        targetCanvas.globalCompositeOperation = 'source-over';
+        targetCanvas.drawImage(sourceImage, 0, 0);
+        targetCanvas.globalCompositeOperation = blendMode;
+        targetCanvas.drawImage(fill.canvas, 0, 0);
+        return targetCanvas;
+    }
+    Renderer.composeUnderSpecialRect = composeUnderSpecialRect;
+    /**
+     * Paints sourceImage over same-sized canvas filled with color
+     */
+    function composeOverRect(sourceImage, color, blendMode, targetCanvas = createCanvas(sourceImage.width, sourceImage.height)) {
+        // Fill with target color
+        targetCanvas.globalCompositeOperation = 'source-over';
+        targetCanvas.fillStyle = color;
+        targetCanvas.fillRect(0, 0, sourceImage.width, sourceImage.height);
+        targetCanvas.globalCompositeOperation = blendMode;
+        targetCanvas.drawImage(sourceImage, 0, 0);
+        return targetCanvas;
+    }
+    Renderer.composeOverRect = composeOverRect;
+    /**
+     * Paints over sourceImage a cutout of it filled with color.
+     */
+    function composeUnderCutout(sourceImage, color, blendMode = 'multiply', canvas = createCanvas(sourceImage.width, sourceImage.height)) {
+        const cut = cutout(sourceImage, color);
+        // Create a copy of sourceImage
+        canvas.globalCompositeOperation = 'source-over';
+        canvas.drawImage(sourceImage, 0, 0);
+        // Multiply with cutout
+        canvas.globalCompositeOperation = blendMode;
+        canvas.drawImage(cut.canvas, 0, 0);
+        return canvas;
+    }
+    Renderer.composeUnderCutout = composeUnderCutout;
+    /**
+     * Paints over sourceImage a same-sized canvas filled with color
+     */
+    function composeUnderRect(sourceImage, color, blendMode = 'multiply', targetCanvas = createCanvas(sourceImage.width, sourceImage.height)) {
+        let fill = createCanvas(sourceImage.width, sourceImage.height, color);
+        targetCanvas.globalCompositeOperation = 'source-over';
+        targetCanvas.drawImage(sourceImage, 0, 0);
+        targetCanvas.globalCompositeOperation = blendMode;
+        targetCanvas.drawImage(fill.canvas, 0, 0);
+        return targetCanvas;
+    }
+    Renderer.composeUnderRect = composeUnderRect;
+    Renderer.ImageCaches = {};
+    Renderer.ImageErrors = {};
+    /**
+     * Switch between compose(Over|Under)(Rect|Cutout)
+     */
+    function compose(composeOver, doCutout, sourceImage, color, blendMode, targetCanvas = createCanvas(sourceImage.width, sourceImage.height)) {
+        if (doCutout) {
+            if (composeOver) {
+                return composeOverCutout(sourceImage, color, blendMode, targetCanvas);
+            }
+            else {
+                return composeUnderCutout(sourceImage, color, blendMode, targetCanvas);
+            }
+        }
+        else {
+            if (composeOver) {
+                return composeOverRect(sourceImage, color, blendMode, targetCanvas);
+            }
+            else {
+                return composeUnderRect(sourceImage, color, blendMode, targetCanvas);
+            }
+        }
+    }
+    Renderer.compose = compose;
+    /**
+     * Fills properties in `target` from `source`.
+     * If `overwrite` is false, only missing properties are copied.
+     * In both cases, brightness is added, contrast is multiplied.
+     * Returns target
+     */
+    function mergeLayerData(target, source, overwrite = false) {
+        for (let k of Object.keys(source)) {
+            if (k === 'brightness' && 'brightness' in target) {
+                if (typeof target.brightness === 'object' && typeof source.brightness === 'number') {
+                    for (const [adjustmentIndex, adjustment] of target.brightness.adjustments.entries()) {
+                        if (typeof adjustment === 'number') {
+                            target.brightness.adjustments[adjustmentIndex] += source.brightness;
+                        }
+                        else {
+                            target.brightness.adjustments[adjustmentIndex][1] += source.brightness;
+                        }
+                    }
+                }
+                else if (typeof target.brightness === 'number' && typeof source.brightness === 'object') {
+                    const brightnessToAdd = target.brightness;
+                    target.brightness = Object.assign({}, source.brightness);
+                    for (const [adjustmentIndex, adjustment] of target.brightness.adjustments.entries()) {
+                        if (typeof adjustment === 'number') {
+                            target.brightness.adjustments[adjustmentIndex] += brightnessToAdd;
+                        }
+                        else {
+                            target.brightness.adjustments[adjustmentIndex][1] += brightnessToAdd;
+                        }
+                    }
+                }
+                else if (typeof target.brightness === 'number' && typeof source.brightness === 'number') {
+                    target.brightness += source.brightness;
+                }
+                else {
+                    throw new Error("Not implemented: cannot merge two gradient brightnesses.");
+                }
+            }
+            else if (k === 'contrast' && 'contrast' in target) {
+                target.contrast *= source.contrast;
+            }
+            else if (overwrite || !(k in target)) {
+                target[k] = source[k];
+            }
+        }
+        return target;
+    }
+    Renderer.mergeLayerData = mergeLayerData;
+    function encodeProcessing(spec) {
+        return JSON.stringify({
+            // z, alpha, show, and frames regulate how layer is rendered onto target canvas
+            src: spec.src,
+            blend: spec.blend,
+            blendMode: spec.blendMode,
+            desaturate: spec.desaturate,
+            brightness: spec.brightness,
+            contrast: spec.contrast,
+            prefilter: spec.prefilter,
+            masksrc: spec.masksrc
+        });
+    }
+    Renderer.encodeProcessing = encodeProcessing;
+    function composeLayersAgain() {
+        composeLayers.apply(Renderer, Renderer.lastCall);
+    }
+    Renderer.composeLayersAgain = composeLayersAgain;
+    function desaturateImage(image, resultCanvas, doCutout = true) {
+        return compose(false, doCutout, image, '#000000', 'saturation', resultCanvas).canvas;
+    }
+    Renderer.desaturateImage = desaturateImage;
+    function filterImage(image, filter, resultCanvas) {
+        if (!resultCanvas) {
+            resultCanvas = createCanvas(image.width, image.height);
+        }
+        resultCanvas.filter = filter;
+        resultCanvas.globalCompositeOperation = 'source-over';
+        resultCanvas.drawImage(image, 0, 0);
+        resultCanvas.filter = '';
+        return resultCanvas.canvas;
+    }
+    Renderer.filterImage = filterImage;
+    function adjustGradientBrightness(image, frameCount, brightness, resultCanvas) {
+        if (brightness.adjustments.length !== 2) {
+            throw new Error("Not Implemented: Brightness gradients can have only exactly 2 stops.");
+        }
+        const gradientInitializations = [];
+        for (const [i, adjustment] of brightness.adjustments.entries()) {
+            let brightnessValue;
+            let offsetValue;
+            // [color |[offset, color]]
+            if (typeof adjustment === 'number') {
+                brightnessValue = adjustment;
+                offsetValue = i;
+            }
+            else {
+                brightnessValue = adjustment[1];
+                offsetValue = adjustment[0];
+            }
+            // lightnenig or darkening
+            if (brightnessValue > 0) {
+                gradientInitializations.push({
+                    grey: gray(brightnessValue),
+                    neutral: "#000000",
+                    offset: offsetValue,
+                    blendMode: 'color-dodge',
+                });
+            }
+            else {
+                gradientInitializations.push({
+                    grey: gray(1 + brightnessValue),
+                    neutral: "#FFFFFF",
+                    offset: offsetValue,
+                    blendMode: 'multiply',
+                });
+            }
+        }
+        // we needmultiple gradients if we are darkening and lightening at the same time
+        if (gradientInitializations[0].blendMode != gradientInitializations[1].blendMode) {
+            const gradients = [];
+            for (const [i, gradientInit] of gradientInitializations.entries()) {
+                gradients.push(createGradient(Object.assign(Object.assign({}, brightness), {
+                    colors: [
+                        [gradientInitializations[0].offset, i === 0 ? gradientInit.grey : gradientInit.neutral],
+                        [gradientInitializations[1].offset, i === 0 ? gradientInit.neutral : gradientInit.grey]
+                    ]
+                })));
+            }
+            const firstGradientApplied = composeUnderSpecialRect(image, gradients[0], gradientInitializations[0].blendMode, frameCount);
+            const secondGradientApplied = composeUnderSpecialRect(firstGradientApplied.canvas, gradients[1], gradientInitializations[1].blendMode, frameCount, resultCanvas);
+            return secondGradientApplied.canvas;
+        }
+        else {
+            const brightnessGradient = createGradient(Object.assign(Object.assign({}, brightness), {
+                colors: [
+                    [gradientInitializations[0].offset, gradientInitializations[0].grey],
+                    [gradientInitializations[1].offset, gradientInitializations[1].grey]
+                ]
+            }));
+            return composeUnderSpecialRect(image, brightnessGradient, gradientInitializations[0].blendMode, frameCount, resultCanvas).canvas;
+        }
+    }
+    Renderer.adjustGradientBrightness = adjustGradientBrightness;
+    function adjustBrightness(image, brightness, resultCanvas, doCutout = true) {
+        if (brightness > 0) {
+            const value = gray(brightness);
+            // color-dodge by X% gray adjusts levels 0%-(100-X)% to 0%-100%
+            return compose(false, doCutout, image, value, 'color-dodge', resultCanvas).canvas;
+            // Other option:
+            // screen by X% gray adjust levels 0%-100% to X%-100%
+            // return composeUnderCutout(image, value, 'screen').canvas;
+        }
+        else {
+            // multiply by X% gray adjusts levels 0%-100% to 0%-X%
+            const value = gray(1 + brightness);
+            return compose(false, doCutout, image, value, 'multiply', resultCanvas).canvas;
+            // Other option:
+            // color-burn by X% gray adjusts levels (100-X)%-100% to 0%-100%
+        }
+    }
+    Renderer.adjustBrightness = adjustBrightness;
+    function adjustLevels(image,
+        /**
+         * scale factor, 1 - no change, >1 - higher contrast, <1 - lower contrast.
+         */
+        factor,
+        /**
+         * shift, 0 - no change, >0 - brighter, <0 - darker
+         */
+        shift, resultCanvas) {
+        if (factor >= 1) {
+            /*
+             color-dodge ( color, X ) = color / (1 - X) ; 0..(1-X) -> 0..1, (1-X) and brighter become white
+             color-burn ( color, Y ) = 1 - (1 - color) / Y ; (1-Y)..1 -> 0..1, (1-Y) and darker become black
+             color-burn ( color-dodge ( color, X ), Y ) = (1-1/Y) + color / (Y-X*Y)
+                                                        = shift   + color * factor
+             given (shift, factor), solving for (X, Y):
+             X = 1-(1-shift)/factor
+             Y = 1/(1 - shift)
+             */
+            const x = 1 - (1 - shift) / factor;
+            const y = 1 / (1 - shift);
+            const c1 = compose(false, false, image, gray(x), 'color-dodge');
+            const c2 = compose(false, false, c1.canvas, gray(y), 'color-burn', resultCanvas);
+            return c2.canvas;
+        }
+        else {
+            /*
+             multiply ( color, X ) = color * X ; 0..1 -> 0..X
+             screen ( color, Y ) = 1 - (1 - color) * (1 - Y) ; 0..1 -> Y..1
+             screen ( multiply ( color, X ), Y ) = 1 - (1 - color * X ) * (1 - Y)
+                                                 = Y     + color * X*(1-Y)
+                                                 = shift + color * factor
+             solving for (X, Y):
+             Y = shift
+             X = factor/(1-shift)
+             */
+            const x = factor / (1 - shift);
+            const y = shift;
+            const c1 = compose(false, false, image, gray(x), 'multiply');
+            const c2 = compose(false, false, c1.canvas, gray(y), 'screen');
+            return c2.canvas;
+        }
+    }
+    Renderer.adjustLevels = adjustLevels;
+    function adjustContrast(image, factor, resultCanvas) {
+        /*
+         contrast is scale by F with origin at 0.5
+        */
+        const shift = 0.5 * (1 - factor);
+        return adjustLevels(image, factor, shift, resultCanvas);
+    }
+    Renderer.adjustContrast = adjustContrast;
+    function adjustBrightnessAndContrast(image, brightness, contrast, resultCanvas) {
+        // = adjustContrast (color + brightness, contrast)
+        const shift = brightness * contrast + 0.5 * (1 - contrast);
+        return adjustLevels(image, contrast, shift, resultCanvas);
+    }
+    Renderer.adjustBrightnessAndContrast = adjustBrightnessAndContrast;
+    const RenderingStepDesaturate = {
+        name: "desaturate",
+        condition(layer, context) {
+            return layer.desaturate;
+        },
+        render(image, layer, context) {
+            context.needsCutout = true;
+            return desaturateImage(image, undefined, false);
+        }
+    };
+    const RenderingStepPrefilter = {
+        name: "prefilter",
+        condition(layer, context) {
+            return layer.prefilter && layer.prefilter !== "none";
+        },
+        render(image, layer, context) {
+            return filterImage(image, layer.prefilter);
+        }
+    };
+    const RenderingStepBrightness = {
+        name: "brightness",
+        condition(layer, context) {
+            return typeof layer.brightness === 'number' && layer.brightness !== 0;
+        },
+        render(image, layer, context) {
+            context.needsCutout = true;
+            return adjustBrightness(image, layer.brightness, undefined, false);
+        }
+    };
+    const RenderingStepBrightnessGradient = {
+        name: "brightness:gradient",
+        condition(layer, context) {
+            return layer.brightness && typeof layer.brightness === 'object' && 'gradient' in layer.brightness;
+        },
+        render(image, layer, context) {
+            context.needsCutout = true;
+            return adjustGradientBrightness(image, context.rects.subspriteFrameCount, layer.brightness, undefined);
+        }
+    };
+    const RenderingStepContrast = {
+        name: "contrast",
+        condition(layer, context) {
+            return typeof layer.contrast === 'number' && layer.contrast !== 1;
+        },
+        render(image, layer, context) {
+            context.needsCutout = true;
+            return adjustContrast(image, layer.contrast, undefined);
+        }
+    };
+    const RenderingStepBlendColor = {
+        name: "blend:color",
+        condition(layer, context) {
+            return layer.blendMode && layer.blend && typeof layer.blend === "string";
+        },
+        render(image, layer, context) {
+            context.needsCutout = true;
+            return composeOverRect(image, layer.blend, layer.blendMode).canvas;
+        }
+    };
+    const RenderingStepBlendGradient = {
+        name: "blend:gradient",
+        condition(layer, context) {
+            return layer.blendMode && layer.blend && typeof layer.blend === 'object' && 'gradient' in layer.blend;
+        },
+        render(image, layer, context) {
+            context.needsCutout = true;
+            let gradient = createGradient(layer.blend);
+            return composeOverSpecialRect(image, gradient, layer.blendMode, context.rects.subspriteFrameCount).canvas;
+        }
+    };
+    const RenderingStepBlendPattern = {
+        name: "blend:pattern",
+        condition(layer, context) {
+            return layer.blendMode && layer.blend && typeof layer.blend === 'object' && 'pattern' in layer.blend;
+        },
+        render(image, layer, context) {
+            context.needsCutout = true;
+            let pattern = Renderer.PatternProvider(layer.blend.pattern);
+            if (!pattern) {
+                return ensureCanvas(image);
+            }
+            return composeOverSpecialRect(image, pattern, layer.blendMode, context.rects.subspriteFrameCount).canvas;
+        }
+    };
+    const RenderingStepMask = {
+        name: "mask",
+        condition(layer, context) {
+            return !!layer.mask;
+        },
+        render(image, layer, context) {
+            return cutoutFrom(ensureCanvas(image).getContext('2d'), layer.mask).canvas;
+        }
+    };
+    const RenderingStepCutout = {
+        name: "cutout",
+        condition(layer, context) {
+            return context.needsCutout;
+        },
+        render(image, layer, context) {
+            return cutoutFrom(ensureCanvas(image).getContext('2d'), layer.image).canvas;
+        }
+    };
+    /**
+     * Rendering steps used. Order matters!
+     */
+    Renderer.RenderingPipeline = [
+        RenderingStepDesaturate,
+        RenderingStepPrefilter,
+        RenderingStepBrightnessGradient,
+        RenderingStepBrightness,
+        RenderingStepContrast,
+        RenderingStepBlendPattern,
+        RenderingStepBlendGradient,
+        RenderingStepBlendColor,
+        RenderingStepMask,
+        RenderingStepCutout
+    ];
+    function processLayer(layer, rects, listener) {
+        let context = {
+            layer: layer,
+            image: layer.image,
+            needsCutout: false,
+            rects: rects,
+            listener: listener
+        };
+        for (let step of Renderer.RenderingPipeline) {
+            if (!step.condition(context.layer, context))
+                continue;
+            let t0 = millitime();
+            let listener = context.listener;
+            context.image = step.render(context.image, context.layer, context);
+            if (listener && listener.processingStep) {
+                listener.processingStep(context.layer.name, step.name, context.image, millitime() - t0);
+            }
+        }
+        return context.image;
+    }
+    Renderer.processLayer = processLayer;
+    function calcLayerRects(layer, layerImageWidth, targetWidth, targetHeight, frameCount) {
+        const frameWidth = targetWidth / frameCount;
+        const subspriteWidth = layer.width || frameWidth;
+        const subspriteHeight = layer.height || targetHeight;
+        const dx = layer.dx || 0;
+        const dy = layer.dy || 0;
+        const subspriteFrameCount = layerImageWidth / subspriteWidth;
+        return {
+            width: targetWidth,
+            height: targetHeight,
+            frameWidth,
+            frameCount,
+            subspriteWidth,
+            subspriteHeight,
+            subspriteFrameCount,
+            dx,
+            dy
+        };
+    }
+    function composeProcessedLayer(layer, targetCanvas, rects) {
+        const image = layer.cachedImage;
+        targetCanvas.filter = 'none';
+        if (typeof layer.alpha === 'number') {
+            targetCanvas.globalAlpha = layer.alpha;
+        }
+        else {
+            targetCanvas.globalAlpha = 1.0;
+        }
+        const { frameWidth, frameCount, subspriteWidth, subspriteHeight, subspriteFrameCount, dx, dy } = rects;
+        if (rects.subspriteFrameCount === frameCount && !layer.frames) {
+            targetCanvas.drawImage(image, dx, dy);
+        }
+        else {
+            for (let i = 0; i < frameCount; i++) {
+                const imageFrameIndex = Math.min(subspriteFrameCount - 1, layer.frames ? layer.frames[i] : Math.floor(i * subspriteFrameCount / frameCount));
+                targetCanvas.drawImage(image, imageFrameIndex * subspriteWidth, 0, subspriteWidth, subspriteHeight, dx + i * frameWidth, dy, subspriteWidth, subspriteHeight);
+            }
+        }
+    }
+    Renderer.composeProcessedLayer = composeProcessedLayer;
+    function composeLayers(targetCanvas, layerSpecs, frameCount, listener) {
+        Renderer.lastCall = [targetCanvas, layerSpecs, frameCount, listener];
+        const t0 = millitime();
+        // Sort layers by z-index, then array index
+        const layers = layerSpecs
+            .filter(layer => layer.show !== false
+                && !(typeof layer.alpha === 'number' && layer.alpha <= 0.0))
+            .map((layer, i) => {
+                if (isNaN(layer.z)) {
+                    console.error("Layer " + (layer.name || layer.src) + " has z-index NaN");
+                    layer.z = 0;
+                }
+                return [layer, i];
+            }) // map to pairs [element, index]
+            .sort((a, b) => {
+                if (a[0].z === b[0].z)
+                    return a[1] - b[1];
+                else
+                    return a[0].z - b[0].z;
+            })
+            .map(e => e[0]); // unwrap values;
+        if (listener && listener.composeLayers)
+            listener.composeLayers(layers);
+        // Tricky part.
+        // We add <img> elements and hook on their onload event.
+        // When image loads, we put it into layer 'image' property and kick maybeRenderResult
+        // When all images are loaded, we call renderResult
+        let rendered = false;
+        let layersLoaded = 0;
+        function renderResult() {
+            rendered = true;
+            targetCanvas.clearRect(0, 0, targetCanvas.canvas.width, targetCanvas.canvas.height);
+            if (listener && listener.beforeRender) {
+                listener.beforeRender(layers);
+            }
+            const targetWidth = targetCanvas.canvas.width;
+            const targetHeight = targetCanvas.canvas.height;
+            const t1 = millitime();
+            for (const layer of layers) {
+                if (layer.show === false)
+                    continue; // Could be disabled due to load error
+                let name = layer.name || layer.src;
+                let image = layer.image;
+                let layerRects = calcLayerRects(layer, image.width, targetWidth, targetHeight, frameCount);
+                let currentProcessing = encodeProcessing(layer);
+                if (layer.cachedProcessing && layer.cachedImage && currentProcessing === layer.cachedProcessing) {
+                    if (listener && listener.layerCacheHit) {
+                        listener.layerCacheHit(layer);
+                    }
+                    image = layer.cachedImage;
+                }
+                else {
+                    if (listener && listener.layerCacheMiss) {
+                        listener.layerCacheMiss(layer);
+                    }
+                    image = processLayer(layer, layerRects, listener);
+                    layer.cachedProcessing = currentProcessing;
+                    layer.cachedImage = image;
+                }
+                composeProcessedLayer(layer, targetCanvas, layerRects);
+                if (listener && listener.composition) {
+                    listener.composition(name, targetCanvas.canvas);
+                }
+            }
+            if (listener && listener.renderingDone)
+                listener.renderingDone(millitime() - t1);
+        }
+        function maybeRenderResult() {
+            if (rendered)
+                return;
+            for (const layer of layers) {
+                if (layer.show !== false && !layer.image)
+                    return;
+                if (layer.masksrc && !layer.mask)
+                    return;
+            }
+            if (listener && listener.loadingDone)
+                listener.loadingDone(millitime() - t0, layersLoaded);
+            try {
+                renderResult();
+            }
+            catch (e) {
+                rendererError(listener, e);
+            }
+        }
+        function loadLayerImage(layer) {
+            Renderer.ImageLoader.loadImage(layer.src, layer, (src, layer, image) => {
+                layersLoaded++;
+                if (listener && listener.loaded) {
+                    listener.loaded(layer.name || 'unnamed', src);
+                }
+                layer.image = image;
+                layer.imageSrc = src;
+                Renderer.ImageCaches[src] = image;
+                maybeRenderResult();
+            }, (src, layer, error) => {
+                // Mark this src as erroneous to avoid blinking due to reload attempts
+                Renderer.ImageErrors[src] = true;
+                if (listener && listener.loadError) {
+                    listener.loadError(layer.name || 'unnamed', src);
+                }
+                else {
+                    console.error('Failed to load image ' + src + (layer.name ? ' for layer ' + layer.name : ''));
+                }
+                layer.show = false;
+                maybeRenderResult();
+            });
+        }
+        function loadLayerMask(layer) {
+            Renderer.ImageLoader.loadImage(layer.masksrc, layer, (src, layer, image) => {
+                layersLoaded++;
+                if (listener && listener.loaded) {
+                    listener.loaded(layer.name || 'unnamed', src);
+                }
+                layer.mask = image;
+                layer.cachedMaskSrc = src;
+                Renderer.ImageCaches[src] = image;
+                maybeRenderResult();
+            }, (src, layer, error) => {
+                // Mark this src as erroneous to avoid blinking due to reload attempts
+                Renderer.ImageErrors[src] = true;
+                if (listener && listener.loadError) {
+                    listener.loadError(layer.name || 'unnamed', src);
+                }
+                else {
+                    console.error('Failed to load mask ' + src + (layer.name ? ' for layer ' + layer.name : ''));
+                }
+                delete layer.masksrc;
+                maybeRenderResult();
+            });
+        }
+        for (const layer of layers) {
+            let needImage = true;
+            if (layer.image) {
+                if (layer.imageSrc === layer.src) {
+                    needImage = false;
+                }
+                else {
+                    // Layer was loaded in previous render, but then its src was changed - purge cache
+                    delete layer.image;
+                    delete layer.imageSrc;
+                }
+            }
+            if (needImage) {
+                if (Renderer.ImageErrors[layer.src]) {
+                    layer.show = false;
+                    continue;
+                }
+                else if (layer.src in Renderer.ImageCaches) {
+                    layer.image = Renderer.ImageCaches[layer.src];
+                    layer.imageSrc = layer.src;
+                }
+                else {
+                    loadLayerImage(layer);
+                }
+            }
+            let needMask = !!layer.masksrc;
+            if (layer.mask) {
+                if (layer.cachedMaskSrc === layer.masksrc) {
+                    needMask = false;
+                }
+                else {
+                    // Layer mask was loaded in previous render, but then its masksrc was changed - purge cache
+                    delete layer.mask;
+                    delete layer.cachedMaskSrc;
+                }
+            }
+            if (needMask) {
+                if (Renderer.ImageErrors[layer.masksrc]) {
+                    delete layer.masksrc;
+                }
+                else if (layer.masksrc in Renderer.ImageCaches) {
+                    layer.mask = Renderer.ImageCaches[layer.masksrc];
+                    layer.cachedMaskSrc = layer.masksrc;
+                }
+                else {
+                    loadLayerMask(layer);
+                }
+            }
+        }
+        maybeRenderResult();
+    }
+    Renderer.composeLayers = composeLayers;
+    function invalidateLayerCaches(layers) {
+        for (let layer of layers) {
+            delete layer.image;
+            delete layer.imageSrc;
+            delete layer.mask;
+            delete layer.cachedMaskSrc;
+            delete layer.cachedImage;
+            delete layer.cachedProcessing;
+        }
+    }
+    Renderer.invalidateLayerCaches = invalidateLayerCaches;
+    function animateLayersAgain() {
+        return animateLayers.apply(Renderer, Renderer.lastAnimateCall);
+    }
+    Renderer.animateLayersAgain = animateLayersAgain;
+    const animatingCanvases = new WeakMap();
+    Renderer.Animations = {};
+    /**
+     * Animation spec provider; default implementation is look up in Renderer.Animations by layer's `animation` property.
+     *
+     * Can be overridden to auto-generate animations, for example.
+     */
+    Renderer.AnimationProvider = layer => Renderer.Animations[layer.animation];
+    /**
+     * Animatable properties of KeyframeSpec and CompositeLayer
+     */
+    Renderer.AnimatableProps = ["alpha", "show", "blend", "brightness", "contrast", "dx", "dy"];
+    function animateLayers(targetCanvas, layerSpecs, listener, autoStop = true) {
+        Renderer.lastAnimateCall = [targetCanvas, layerSpecs, listener, autoStop];
+        const keyframeCaches = {};
+        function invalidateCaches() {
+            for (let key in keyframeCaches)
+                delete keyframeCaches[key];
+        }
+        let schedule = {};
+        // this mess should become a class already
+        const animatingCanvas = {
+            target: targetCanvas,
+            keyframeCaches: keyframeCaches,
+            animations: [],
+            playing: false,
+            busy: false,
+            start() {
+                if (this.playing)
+                    this.stop();
+                this.playing = true;
+                // stop previous animation on this targetCanvas, if present
+                let oldAnimation = animatingCanvases.get(targetCanvas);
+                if (oldAnimation != null) {
+                    oldAnimation.stop();
+                }
+                animatingCanvases.set(targetCanvas, this);
+                let usedAnimations = {};
+                for (let layer of layerSpecs) {
+                    if (!layer.src || layer.show === false)
+                        continue;
+                    if (layer.animation) {
+                        let spec = Renderer.AnimationProvider(layer);
+                        if (!spec) {
+                            console.error("Layer '" + (layer.name || layer.src) + "' animation '" + layer.animation + "' not found");
+                            continue;
+                        }
+                        let complex = false;
+                        if ('frames' in spec) {
+                            let frames = spec.frames, duration = spec.duration;
+                            spec = {
+                                keyframes: []
+                            };
+                            for (let i = 0; i < frames; i++) {
+                                spec.keyframes.push({ frame: i, duration: duration });
+                            }
+                        }
+                        else {
+                            for (let kf of spec.keyframes) {
+                                for (let ap of Renderer.AnimatableProps) {
+                                    if (ap in kf) {
+                                        complex = true;
+                                        break;
+                                    }
+                                }
+                                if (complex)
+                                    break;
+                            }
+                        }
+                        let animation = usedAnimations[layer.animation];
+                        if (!animation) {
+                            animation = usedAnimations[layer.animation] = {
+                                name: layer.animation,
+                                complex: complex,
+                                spec: spec,
+                                timeoutId: 0,
+                                keyframeIndex: 0,
+                                keyframe: spec.keyframes[0],
+                                layers: [],
+                                time: 0
+                            };
+                        }
+                        animation.layers.push(layer);
+                        applyKeyframe(animation.keyframe, layer);
+                    }
+                    else {
+                        layer.frames = [0];
+                    }
+                }
+                this.animations = Object.values(usedAnimations);
+                for (let animation of this.animations) {
+                    scheduleNextKeyframe(animation);
+                    if (listener && listener.keyframe)
+                        listener.keyframe(animation.name, animation.keyframeIndex, animation.keyframe);
+                }
+                compose().catch((e) => {
+                    if (e)
+                        console.error(e);
+                });
+            },
+            stop() {
+                if (!this.playing)
+                    return;
+                this.playing = false;
+                animatingCanvases.delete(targetCanvas);
+                for (let info of this.animations) {
+                    if (info.timeoutId)
+                        clearTimeout(info.timeoutId);
+                }
+                schedule = {};
+                this.animations.splice(0);
+                invalidateCaches();
+                if (listener && listener.animationStop)
+                    listener.animationStop();
+            },
+            invalidateCaches,
+            time: 0,
+            redraw() {
+                compose().catch((e) => {
+                    if (e)
+                        console.error(e);
+                });
+            }
+        };
+        function genAnimationSpec() {
+            let j = {};
+            for (let animation of animatingCanvas.animations) {
+                if (animation.complex) {
+                    j[animation.name] = animation.keyframeIndex;
+                }
+                else {
+                    j[animation.name] = animation.keyframe.frame;
+                }
+            }
+            return JSON.stringify(j);
+        }
+        function scheduleNextKeyframe(animation) {
+            if (animation.keyframe.duration <= 0)
+                return;
+            let t1 = animation.time + animation.keyframe.duration;
+            let tasks = schedule[t1];
+            if (!tasks) {
+                schedule[t1] = tasks = [];
+                animation.timeoutId = window.setTimeout(() => {
+                    try {
+                        delete schedule[t1];
+                        animatingCanvas.time = Math.max(t1, animatingCanvas.time);
+                        for (let task of tasks)
+                            task();
+                        compose().catch((e) => {
+                            if (e)
+                                console.error(e);
+                        });
+                    }
+                    catch (e) {
+                        rendererError(listener, e);
+                    }
+                }, animation.keyframe.duration);
+            }
+            else {
+                animation.timeoutId = 0;
+            }
+            tasks.push(() => {
+                animation.time = t1;
+                nextKeyframe(animation);
+            });
+        }
+        function applyKeyframe(keyframe, layer) {
+            layer.frames = [keyframe.frame];
+            for (let ap of Renderer.AnimatableProps) {
+                if (ap in keyframe)
+                    layer[ap] = keyframe[ap];
+            }
+        }
+        function nextKeyframe(animation) {
+            let keyframes = animation.spec.keyframes;
+            animation.keyframeIndex = (animation.keyframeIndex + 1) % keyframes.length;
+            animation.keyframe = keyframes[animation.keyframeIndex];
+            for (let layer of animation.layers) {
+                applyKeyframe(animation.keyframe, layer);
+            }
+            scheduleNextKeyframe(animation);
+            if (listener && listener.keyframe)
+                listener.keyframe(animation.name, animation.keyframeIndex, animation.keyframe);
+        }
+        function stopCheck() {
+            if (autoStop && animatingCanvas.time > 0 && !(document.body.contains(targetCanvas.canvas))) {
+                /* the canvas was removed from DOM. we exclude frame 0 because it might not yet be added */
+                animatingCanvas.stop();
+                return true;
+            }
+            return false;
+        }
+        function compose() {
+            if (stopCheck() || animatingCanvas.busy) {
+                return Promise.reject();
+            }
+            animatingCanvas.busy = true;
+            return new Promise((resolve, reject) => {
+                requestAnimationFrame(() => {
+                    animatingCanvas.busy = false;
+                    try {
+                        doCompose0();
+                        resolve();
+                    }
+                    catch (e) {
+                        rendererError(listener, e);
+                        reject(e);
+                    }
+                });
+            });
+            function doCompose0() {
+                let spec = genAnimationSpec();
+                let cachedCanvas = keyframeCaches[spec];
+                if (cachedCanvas) {
+                    const t0 = millitime();
+                    targetCanvas.clearRect(0, 0, targetCanvas.canvas.width, targetCanvas.canvas.height);
+                    targetCanvas.globalAlpha = 1.0;
+                    targetCanvas.drawImage(cachedCanvas.canvas, 0, 0);
+                    if (listener && listener.keyframeRender) {
+                        listener.keyframeRender(spec, true, millitime() - t0);
+                    }
+                }
+                else {
+                    if (listener && listener.keyframeRender) {
+                        listener.keyframeRender(spec, false, 0);
+                    }
+                    const myListener = Object.assign({}, listener, {
+                        renderingDone(time) {
+                            let canvas = createCanvas(targetCanvas.canvas.width, targetCanvas.canvas.height);
+                            canvas.drawImage(targetCanvas.canvas, 0, 0);
+                            keyframeCaches[genAnimationSpec()] = canvas;
+                            if (listener && listener.renderingDone)
+                                listener.renderingDone.apply(listener, arguments);
+                        }
+                    });
+                    try {
+                        composeLayers(targetCanvas, layerSpecs, 1, myListener);
+                    }
+                    catch (e) {
+                        animatingCanvas.stop();
+                        throw e;
+                    }
+                }
+            }
+        }
+        animatingCanvas.start();
+        return (Renderer.lastAnimation = animatingCanvas);
+    }
+    Renderer.animateLayers = animateLayers;
+    /**
+     * Linear interpolation.
+     *
+     * f(0) = min,
+     * f(1) = max.
+     */
+    function lint(value, min, max, allowOverflow = false) {
+        if (!allowOverflow)
+            value = Math.min(1, Math.max(0, value));
+        return value * (max - min) + min;
+    }
+    Renderer.lint = lint;
+    function lintArray(value, mins, maxes, allowOverflow = false) {
+        return mins.map((min, i) => lint(value, min, maxes[i], allowOverflow));
+    }
+    Renderer.lintArray = lintArray;
+    function lintStaged(value, points) {
+        value = Math.min(1, Math.max(0, value));
+        const n = points.length - 1;
+        let i = (value * n) | 0;
+        if (i === n)
+            i = n - 1;
+        return lint(value * n - i, points[i], points[i + 1]);
+    }
+    Renderer.lintStaged = lintStaged;
+    function lintRgb(value, min, max) {
+        min = tinycolor(min).toRgb();
+        max = tinycolor(max).toRgb();
+        return tinycolor({
+            r: lint(value, min.r, max.r),
+            g: lint(value, min.g, max.g),
+            b: lint(value, min.b, max.b)
+        });
+    }
+    Renderer.lintRgb = lintRgb;
+    function lintRgbStaged(value, points) {
+        value = Math.min(1, Math.max(0, value));
+        const n = points.length - 1;
+        let i = (value * n) | 0;
+        if (i === n)
+            i = n - 1;
+        return lintRgb(value * n - i, points[i], points[i + 1]);
+    }
+    Renderer.lintRgbStaged = lintRgbStaged;
+    window.Renderer = Renderer;
+    // Expose library functions needed by model evaluation, to global ns
+    window.lint = Renderer.lint;
+    window.lintArray = Renderer.lintArray;
+    window.lintStaged = Renderer.lintStaged;
+    window.lintRgb = Renderer.lintRgb;
+    window.lintRgbStaged = Renderer.lintRgbStaged;
 })(Renderer || (Renderer = {}));
diff --git a/game/04-Variables/canvasmodel-main.js b/game/04-Variables/canvasmodel-main.js
index 1a03b69994..f0371d0cbb 100644
--- a/game/04-Variables/canvasmodel-main.js
+++ b/game/04-Variables/canvasmodel-main.js
@@ -538,11 +538,19 @@ Renderer.CanvasModels["main"] = {
 			const filterPrototype = filterPrototypeLibrary[hairType] || filterPrototypeLibrary.all;
 			const filter = {
 				blend: clone(filterPrototype),
+				brightness: {
+					gradient: filterPrototype.gradient,
+					values: filterPrototype.values,
+					adjustments: [[], []]
+				},
 				blendMode: "hard-light"
 			};
 			for (const colorIndex in filter.blend.colors) {
 				filter.blend.colors[colorIndex][0] = filter.blend.lengthFunctions[0](hairLength, filter.blend.colors[colorIndex][0]);
 				filter.blend.colors[colorIndex][1] = setup.colours.hair_map[gradient.colours[colorIndex]].canvasfilter.blend;
+
+				filter.brightness.adjustments[colorIndex][0] = filter.blend.lengthFunctions[0](hairLength, filter.blend.colors[colorIndex][0]);
+				filter.brightness.adjustments[colorIndex][1] = setup.colours.hair_map[gradient.colours[colorIndex]].canvasfilter.brightness || 0;
 			}
 			Renderer.mergeLayerData(filter, setup.colours.sprite_prefilters[prefilterName], true);
 
diff --git a/game/overworld-town/loc-shop/hairDressers.twee b/game/overworld-town/loc-shop/hairDressers.twee
index 2d58449902..1709213d75 100644
--- a/game/overworld-town/loc-shop/hairDressers.twee
+++ b/game/overworld-town/loc-shop/hairDressers.twee
@@ -196,7 +196,7 @@ You are in the hairdressers. Here you can have your hair cut or dyed.
 				<</if>>
 				<<if $money gte _currentCost + 6000 or ($dyeOption isnot "noChange" and $dyeOption isnot "removeDye")>>
 					<<for _i to 0; _i lt _dyeNames.length; _i++>>
-						<<if $haircolour is $hairdressersHairColour[_i] or $naturalhaircolour is $hairdressersHairColour[_i]>>
+						<<if ($haircolour is $hairdressersHairColour[_i] and $hairColourStyle is "simple")  or $naturalhaircolour is $hairdressersHairColour[_i]>>
 							<<continue>>
 						<</if>>
 						<<set _dyeOptions[_dyeNames[_i]] to $hairdressersHairColour[_i]>>
-- 
GitLab