♻️ better types system

This commit is contained in:
2019-01-22 12:52:22 +01:00
parent 6394f2a869
commit abd29d2999
27 changed files with 635 additions and 300 deletions

View File

@@ -5,54 +5,89 @@ export default class Compose extends Node {
constructor(options) {
super('Compose', {
fg: 'Image',
fgX: 'Number',
fgY: 'Number',
bg: 'Image',
bgX: 'Number',
bgY: 'Number',
width: 'Number',
height: 'Number',
mode: [
'source-over',
'source-in',
'source-out',
'source-atop',
'destination-over',
'destination-in',
'destination-out',
'destination-atop',
'lighter',
'copy',
'xor',
'multiply',
'screen',
'overlay',
'darken',
'lighten',
'color-dodge',
'color-burn',
'hard-light',
'soft-light',
'difference',
'exclusion',
'hue',
'saturation',
'color',
'luminosity',
]
fg: {
type: 'Image'
},
fgX: {
type: 'Number',
default: 0,
step: 1,
},
fgY: {
type: 'Number',
default: 0,
step: 1,
},
bg: {
type: 'Image',
},
bgX: {
type: 'Number',
default: 0,
step: 1,
},
bgY: {
type: 'Number',
default: 0,
step: 1,
},
width: {
type: 'Number',
default: 100,
step: 1,
min: 1
},
height: {
type: 'Number',
default: 100,
step: 1,
min: 1,
},
mode: {
type: 'String',
default: 'source-over',
enum: [
'source-over',
'source-in',
'source-out',
'source-atop',
'destination-over',
'destination-in',
'destination-out',
'destination-atop',
'lighter',
'copy',
'xor',
'multiply',
'screen',
'overlay',
'darken',
'lighten',
'color-dodge',
'color-burn',
'hard-light',
'soft-light',
'difference',
'exclusion',
'hue',
'saturation',
'color',
'luminosity',
]
}
}, {
image: 'Image'
});
this.options = options;
image: {
type: 'Image'
}
}, options);
}
get width() {
return this.__in.width.value || this.options.width;
return this.__in.width.value;
}
get height() {
return this.__in.height.value || this.options.height;
return this.__in.height.value;
}
get fg() {
@@ -85,11 +120,10 @@ export default class Compose extends Node {
}
}
get mode() {
return this.__in.mode.value || 'source-over';
}
__update() {
if (!this.width || !this.height) {
return;
}
const canvas = createCanvas(this.width, this.height);
if (this.bg) {

View File

@@ -1,13 +1,13 @@
import {Inputs, Outputs} from './io.js';
import {Inputs, Outputs} from './io';
import uuidv4 from 'uuid/v4';
export default class Node {
constructor(name, inputNames, outputNames) {
constructor(name, inputDefinition, outputDefiniton, options) {
this.name = name;
this.id = uuidv4();
this.__in = new Inputs(inputNames, this);
this.__out = new Outputs(outputNames, this);
this.__in = new Inputs(inputDefinition, this);
this.__out = new Outputs(outputDefiniton, this);
this.__in.update = (name) => this.__update([name]);
}

View File

@@ -5,21 +5,33 @@ export default class Resize extends Node {
constructor(options) {
super('Resize', {
image: 'Image',
width: 'Number',
height: 'Number',
image: {
type: 'Image',
},
width: {
type: 'Number',
default: 100,
min: 1,
},
height: {
type: 'Number',
default: 100,
min: 1,
},
}, {
image: 'Image'
image: {
type: 'Image',
}
});
this.options = options;
}
get width() {
return this.__in.width.value || this.options.width;
return this.__in.width.value;
}
get height() {
return this.__in.height.value || this.options.height;
return this.__in.height.value;
}
__update() {

View File

@@ -5,8 +5,14 @@ export default class BrightnessContrast extends WebGL {
constructor() {
super('BrightnessContrast', {
brightness: 'Number',
contrast: 'Number',
brightness: {
type: 'Number',
default: 1,
},
contrast: {
type: 'Number',
default: 1,
},
})
}
@@ -23,23 +29,24 @@ export default class BrightnessContrast extends WebGL {
uniform vec2 u_brightnessContrast;
void main() {
vec3 c = texture2D(u_image, v_texCoord).rgb * u_brightnessContrast[0];
vec4 pixelColor = texture2D(u_image, v_texCoord);
vec3 c = pixelColor.rgb * u_brightnessContrast[0];
gl_FragColor = vec4(
clamp(
(c - 0.5) * u_brightnessContrast[1] + 0.5,
0.0, 1.0),
1
pixelColor.a
);
}
`
}
get contrast() {
return this.in.contrast.value || 1;
return this.in.contrast.value
}
get brightness() {
return this.in.brightness.value || 1;
return this.in.brightness.value;
}
_setParams(gl, program) {

View File

@@ -13,11 +13,30 @@ export default class GreenScreen extends WebGL {
constructor() {
super('GreenScreen', {
balance: 'Number',
screen: 'Color',
screenWeight: 'Number',
clipBlack: 'Number',
clipWhite: 'Number',
balance: {
type: 'Number',
default: 0.5,
step: 0.1,
},
screen: {
type: 'Color',
default: '#2CD6A4',
},
screenWeight: {
type: 'Number',
default: 1,
min: 0,
max: 1,
step: 0.1,
},
clipBlack: {
type: 'Number',
default: 0,
},
clipWhite: {
type: 'Number',
default: 1,
},
})
}
@@ -52,13 +71,13 @@ export default class GreenScreen extends WebGL {
float screenFmax = max(max(screen.r, screen.g), screen.b); //Max. value of RGB
vec3 screenPrimary = step(screenFmax, screen.rgb);
float screenSecondaryComponents = dot(1.0 - screenPrimary, screen.rgb);
float screenSat = fmax - mix(secondaryComponents - fmin, secondaryComponents / 2.0, balance);
float screenSat = screenFmax - mix(screenSecondaryComponents - screenFmin, screenSecondaryComponents / 2.0, balance);
pixelSat = fmax - mix(secondaryComponents - fmin, secondaryComponents / 2.0, balance);
// solid pixel if primary color component is not the same as the screen color
float diffPrimary = dot(abs(pixelPrimary - screenPrimary), vec3(1.0));
float solid = step(1.0, step(pixelSat, 0.1) + step(fmax, 0.1) + diffPrimary);
float solid = step(1.0, pixelSat + step(fmax, 0.1) + diffPrimary);
/*
Semi-transparent pixel if the primary component matches but if saturation is less
@@ -77,26 +96,27 @@ export default class GreenScreen extends WebGL {
}
get clipBlack() {
return (this.in.clipBlack.value || 0);
return this.in.clipBlack.value;
}
get clipWhite() {
return (this.in.clipWhite.value || 0);
return this.in.clipWhite.value;
}
get screenWeight() {
return (this.in.screenWeight.value || 1);
return this.in.screenWeight.value;
}
get screen() {
return hexColorTOvec3(this.in.screen.value || '#00FF00');
return hexColorTOvec3(this.in.screen.value);
}
get balance() {
return (this.in.balance.value || 0.5);
return this.in.balance.value;
}
_setParams(gl, program) {
console.log(this.balance, this.clipBlack, this.clipWhite, this.screenWeight)
gl.uniform3f(gl.getUniformLocation(program, 'screen'), ...this.screen);
gl.uniform1f(gl.getUniformLocation(program, 'balance'), this.balance);
gl.uniform1f(gl.getUniformLocation(program, 'clipBlack'), this.clipBlack);

View File

@@ -5,9 +5,18 @@ export default class HSV extends WebGL {
constructor() {
super('HSV', {
hue: 'Number',
saturation: 'Number',
value: 'Number',
hue: {
type: 'Number',
default: 0,
},
saturation: {
type: 'Number',
default: 1,
},
value: {
type: 'Number',
default: 1,
}
})
}
@@ -50,15 +59,15 @@ export default class HSV extends WebGL {
}
get hue() {
return ((this.in.hue.value || 0) % 360) / 360;
return ((this.in.hue.value) % 360) / 360;
}
get saturation() {
return this.in.saturation.value || 1;
return this.in.saturation.value;
}
get value() {
return this.in.value.value || 1;
return this.in.value.value;
}
_setParams(gl, program) {

View File

@@ -5,7 +5,10 @@ export default class Sepia extends WebGL {
constructor() {
super('Sepia', {
amount: 'Number',
amount: {
type: 'Number',
default: 0,
},
})
}
@@ -22,7 +25,7 @@ export default class Sepia extends WebGL {
uniform float amount;
void main() {
vec3 color = texture2D(u_image, v_texCoord).rgb;
vec4 color = texture2D(u_image, v_texCoord);
float r = color.r;
float g = color.g;
float b = color.b;
@@ -31,13 +34,13 @@ export default class Sepia extends WebGL {
color.g = min(1.0, (r * 0.349 * amount) + (g * (1.0 - (0.314 * amount))) + (b * 0.168 * amount));
color.b = min(1.0, (r * 0.272 * amount) + (g * 0.534 * amount) + (b * (1.0 - (0.869 * amount))));
gl_FragColor = vec4(color, 1);
gl_FragColor = color;
}
`
}
get amount() {
return this.in.amount.value || 0;
return this.in.amount.value;
}
/**

View File

@@ -8,9 +8,13 @@ export default class WebGL extends Node {
constructor(name, inputs={}) {
super(name, Object.assign({
image: 'Image',
image: {
type: 'Image',
},
}, inputs), {
image: 'Image'
image: {
type: 'Image',
},
});
}

View File

@@ -4,7 +4,9 @@ export default class ToBlob extends Node {
constructor(options) {
super('Webcam', {}, {
image: 'Image',
image: {
type: 'Image'
},
});
this.start();
}
@@ -16,8 +18,8 @@ export default class ToBlob extends Node {
console.log(last(devices));
const stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { min: 1024, ideal: 1280, max: 1920 },
height: { min: 776, ideal: 720, max: 1080 },
width: { min: 1024, ideal: 1920, max: 1920 },
height: { min: 776, ideal: 1080, max: 1080 },
deviceId: last(devices).deviceId
},
});

12
src/nodes/io.d.ts vendored
View File

@@ -1,12 +0,0 @@
import * as types from './types'
export class Inputs<T extends string> {
constructor(variables: T[]);
[k: string]: types.Input<any>
}
export class Outputs<T extends string> {
constructor(variables: T[]);
[k: string]: types.Input<any>
}

View File

@@ -1,175 +0,0 @@
import * as types from './types';
export class Input {
constructor(name, owner) {
this.__output = null;
this.__value = null;
this.__name = name;
this.__owner = owner
this.__listeners = [];
this.__onchangelistener = (value) => {
this.value = value;
this.__notifyListeners();
}
}
get id() {
return `${this.__owner.id}-${this.__name}`;
}
get name() {
return this.__name;
}
get value() {
return this.__value;
}
set value(value) {
this.__value = value;
this.__owner.update(this.name);
}
connect(output) {
this.disconnect();
this.__output = output
this.__output.onchange(this.__onchangelistener);
}
disconnect(output) {
if (this.__output) {
this.__output.offchange(this.__onchangelistener);
this.__output = null;
}
}
get output() {
return this.__output;
}
__notifyListeners() {
for (let listener of this.__listeners) {
listener(this.value, this.name);
}
}
onchange(listener) {
this.__listeners.push(listener);
}
offchange(listener) {
this.__listeners = this.__listeners
.filter((l) => l !== listener);
}
serialize() {
return {
value: this.__output ? null : this.__value,
output: this.__output ? this.__output.id: null,
}
}
}
export class Output {
constructor(name, owner) {
this.__value = null;
this.__name = name;
this.__owner = owner
this.__listeners = [];
}
get id() {
return `${this.__owner.id}-${this.__name}`;
}
get name() {
return this.__name;
}
get value() {
return this.__value;
}
set value(value) {
this.__value = value;
this.__notifyListeners();
}
__notifyListeners() {
for (let listener of this.__listeners) {
listener(this.value, this.name);
}
}
onchange(listener) {
this.__listeners.push(listener);
}
offchange(listener) {
this.__listeners = this.__listeners
.filter((l) => l !== listener);
}
}
export class Inputs {
constructor(variables, owner) {
this.__values = {};
this.__owner = owner;
this.variables = variables;
for (let name of Object.keys(this.variables)) {
this.__values[name] = new Input(name, this);
Object.defineProperty(this, name, {
get() {
return this.__values[name];
},
})
}
}
get id() {
return `${this.__owner.id}-in`;
}
update(changed) {
}
serialize() {
const res = {};
for (let name of Object.keys(this.variables)) {
res[name] = this.__values[name].serialize();
}
return res;
}
}
export class Outputs {
constructor(variables, owner) {
this.__values = {};
this.__owner = owner;
this.variables = variables;
for (let name of Object.keys(this.variables)) {
this.__values[name] = new Output(name, this);
Object.defineProperty(this, name, {
get() {
return this.__values[name];
},
})
}
}
get id() {
return `${this.__owner.id}-out`;
}
serialize() {
return null;
}
}

View File

@@ -0,0 +1,97 @@
const isNil = (val) => val === null || val === undefined;
export const defaultConstrainSatisfied = (value) => {
return !isNil(value);
}
export const imageConstrainSatisfied = (value) => {
return value instanceof HTMLCanvasElement ||
value instanceof Image ||
value instanceof HTMLVideoElement ||
value instanceof ImageData ||
value instanceof ImageBitmap;
};
export const colorConstrainSatisfied = (value) => {
return /#?([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})/.test(value);
};
export const numberConstrainsSatisfied = (definition, value) => {
return typeof(value) === 'number' && !isNaN(value) &&
(isNil(definition.min) || (value >= definition.min)) &&
(isNil(definition.max) || (value <= definition.max))
}
export const valueConstrainsSatisfied = (definition, value) => {
return defaultConstrainSatisfied(value) && (
definition.type === 'Number' ? numberConstrainsSatisfied(definition, value) :
definition.type === 'Color' ? colorConstrainSatisfied(value) :
definition.type === 'Image' ? imageConstrainSatisfied(value) :
true
)
};
export default class AbstractIO {
constructor(name, definition, owner) {
this.__name = name;
this.__owner = owner
this.__definition = definition;
this.__value = null;
this.__listeners = [];
}
get definition() {
return this.__definition;
}
get __defaultValue() {
return this.__definition.default;
}
get id() {
return `${this.__owner.id}-${this.__name}`;
}
get name() {
return this.__name;
}
get type() {
return this.__definition.type;
}
get value() {
return this.__getValue;
}
set value(val) {
this.__setValue(val);
}
__getValue() {
return (valueConstrainsSatisfied(this.__definition, this.__value)) ? this.__value : this.__defaultValue;
}
__setValue(value) {
if (valueConstrainsSatisfied(this.__definition, value)) {
this.__value = value;
}
}
__notifyListeners() {
for (let listener of this.__listeners) {
listener(this.value, this.name);
}
}
onchange(listener) {
this.__listeners.push(listener);
}
offchange(listener) {
this.__listeners = this.__listeners
.filter((l) => l !== listener);
}
}

View File

@@ -0,0 +1,28 @@
export default class AbstractIOSet {
constructor(variables, owner) {
this.__values = {};
this.__owner = owner;
this.variables = variables;
for (let name of Object.keys(this.variables)) {
this.__values[name] = this.__createProperty(name, variables[name]);
Object.defineProperty(this, name, {
get() {
return this.__values[name];
},
})
}
}
__createProperty(name, definition) {
}
update(changed) {
}
}

49
src/nodes/io/Input.js Normal file
View File

@@ -0,0 +1,49 @@
import AbstractIO from './AbstractIO';
export default class Input extends AbstractIO {
constructor(name, definition, owner) {
super(name, definition, owner);
this.__output = null;
this.__onchangelistener = (value) => {
this.value = value;
this.__notifyListeners();
}
}
get value() {
return this.__getValue();
}
set value(value) {
this.__setValue(value);
this.__owner.update(this.name);
}
get output() {
return this.__output;
}
connect(output) {
if (output && output.type === this.type) {
this.disconnect();
this.__output = output
this.__output.onchange(this.__onchangelistener);
}
}
disconnect(output) {
if (this.__output) {
this.__output.offchange(this.__onchangelistener);
this.__output = null;
}
}
serialize() {
return {
value: this.__output ? null : this.__value,
output: this.__output ? this.__output.id: null,
}
}
}

25
src/nodes/io/Inputs.js Normal file
View File

@@ -0,0 +1,25 @@
import Input from './Input';
import AbstractIOSet from './AbstractIOSet';
export default class Inputs extends AbstractIOSet {
constructor(variables, owner) {
super(variables, owner);
}
__createProperty(name, definition) {
return new Input(name, definition, this);
}
get id() {
return `${this.__owner.id}-in`;
}
serialize() {
const res = {};
for (let name of Object.keys(this.variables)) {
res[name] = this.__values[name].serialize();
}
return res;
}
}

View File

@@ -0,0 +1,36 @@
import Inputs from './Inputs';
describe('Inputs', () => {
it('should create with default values', () => {
const owner = {
id: 'owner',
update(name) {
}
};
const inputs = new Inputs({
x: {
type: 'Number',
min: 0,
max: 1,
default: 0,
},
y: {
type: 'Number',
default: 1
},
foo: {
type: 'String',
default: 'foo'
},
}, owner)
expect(inputs.x.type).toBe('Number');
expect(inputs.x.value).toBe(0);
expect(inputs.y.value).toBe(1);
inputs.x.value = 1;
expect(inputs.x.value).toBe(1);
});
});

40
src/nodes/io/Output.js Normal file
View File

@@ -0,0 +1,40 @@
import AbstractIO from './AbstractIO';
export default class Output extends AbstractIO {
constructor(name, definition, owner) {
super(name, definition, owner);
}
get id() {
return `${this.__owner.id}-${this.__name}`;
}
get name() {
return this.__name;
}
get value() {
return this.__getValue();
}
set value(value) {
this.__setValue(value);
this.__notifyListeners();
}
__notifyListeners() {
for (let listener of this.__listeners) {
listener(this.value, this.name);
}
}
onchange(listener) {
this.__listeners.push(listener);
}
offchange(listener) {
this.__listeners = this.__listeners
.filter((l) => l !== listener);
}
}

20
src/nodes/io/Outputs.js Normal file
View File

@@ -0,0 +1,20 @@
import Output from './Output';
import AbstractIOSet from './AbstractIOSet';
export default class Outputs extends AbstractIOSet {
constructor(variables, owner) {
super(variables, owner);
}
__createProperty(name, definition) {
return new Output(name, definition, this);
}
get id() {
return `${this.__owner.id}-out`;
}
serialize() {
return null;
}
}

View File

@@ -0,0 +1,30 @@
import Outputs from './Outputs';
describe('Outputs', () => {
it('should create with default values', () => {
const owner = {
id: 'owner',
};
const inputs = new Outputs({
x: {
type: 'Number',
min: 0,
max: 1,
default: 0,
},
y: {
type: 'Number',
default: 1
},
foo: {
type: 'String',
default: 'foo'
},
}, owner)
expect(inputs.x.type).toBe('Number');
expect(inputs.x.value).toBe(0);
expect(inputs.y.value).toBe(1);
});
});

4
src/nodes/io/index.js Normal file
View File

@@ -0,0 +1,4 @@
export {default as Input} from './Input';
export {default as Inputs} from './Inputs';
export {default as Output} from './Output';
export {default as Outputs} from './Outputs';

View File

@@ -22,6 +22,7 @@
:selectedOutput="selectedOutput"
@outputSelected="selectedOutput = $event"
@inputSelected="selectedInput = $event"
@removeNode="removeNode"
/>
</Draggable>
<ContextMenu
@@ -140,6 +141,11 @@ export default {
node: new node(),
});
this.contextMenuPosition = null;
},
removeNode(nodeToRemove) {
const index = this.graph.findIndex(({node}) => node === nodeToRemove);
console.log('nodeIndex', index);
this.graph.splice(index, 1);
}
},
computed: {

View File

@@ -21,8 +21,9 @@
>&times;</span>
</div>
<Value
:type="type"
:value="node.in[name]"
:io="node.in[name]"
direction="input"
@change="(value) => node.in[name].value = value"
class="node-var__value"
/>
</div>
@@ -43,12 +44,18 @@
@click="$emit('outputSelected', node.out[name])"
>{{ name }} <!--<i>{{typeName(type)}}</i>--></div>
<Value
:type="type"
:value="node.out[name]"
:io="node.out[name]"
direction="output"
class="node-var__value"
/>
</div>
</div>
<div
class="node__remove"
@click="removeNode"
>
&times;
</div>
</div>
</template>
<script>
@@ -95,6 +102,9 @@ export default {
}
return ins;
},
removeNode() {
this.$emit('removeNode', this.node);
}
}
}
</script>
@@ -144,4 +154,18 @@ export default {
.node__var {
margin-bottom: 2px
}
.node__remove {
position: absolute;
top: -20px;
right: -20px;
background-color: red;
color: white;
border-radius: 50%;
width: 20px;
height: 20px;
display: flex;
justify-content: center;
align-items: center;
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<div
class="image-value"
@click="download"
@click="onClick"
>
<canvas class="image-value__thumb" ref="thumb" />
<canvas class="image-value__preview" ref="preview" />
@@ -14,9 +14,17 @@ import {paintToCanvas, mediaSize} from '../../../src/nodes/canvas.js'
export default {
props: {
io: {
type: null,
required: true,
},
value: {
type: null,
required: true,
},
direction: {
type: String,
required: true,
}
},
watch: {
@@ -34,7 +42,7 @@ export default {
mounted() {
this.$nextTick(() => {
this.redraw();
this.value.onchange(this.redraw);
this.io.onchange(this.redraw);
})
},
watch: {
@@ -44,9 +52,12 @@ export default {
}
}
},
beforeDestroy() {
this.isRunning = false;
},
methods: {
redrawCanvas(canvas) {
const value = this.value.value;
const value = this.value;
/** @type {HTMLCanvasElement} */
const ctx = canvas.getContext('2d');
if (!value) {
@@ -70,6 +81,35 @@ export default {
this.redrawCanvas(this.$refs.preview)
}
},
onClick() {
if (this.direction === 'output') {
this.download();
} else {
this.selectFile();
}
},
selectFile() {
const input = document.createElement('input');
window.$$input = input;
input.type = 'file';
input.accept = 'image/*';
input.onchange = (event) => {
const files = event.target.files;
console.log(event);
if (files.length) {
const fr = new FileReader()
fr.onload = (event) => {
const image = new Image();
image.onload = () => {
this.$emit('change', image);
}
image.src = event.target.result;
}
fr.readAsDataURL(files[0]);
}
}
input.click();
},
download() {
const a = document.createElement('a');
a.href = this.$refs.preview.toDataURL();

View File

@@ -1,10 +1,10 @@
<template>
<select v-model="value.value">
<select v-model="io.value">
<option
v-for="t in type"
:value="t"
:key="t"
>{{ t }}</option>
v-for="value in io.definition.enum"
:value="value"
:key="value"
>{{ value }}</option>
</select>
</template>
<script>
@@ -14,7 +14,7 @@ export default {
type: null,
required: true,
},
value: {
io: {
type: null,
required: true,
}

View File

@@ -1,6 +1,6 @@
<template>
<input
v-model="value.value"
v-model="value"
:type="
type === 'Number' ? 'number' :
type === 'Color' ? 'color' :
@@ -11,7 +11,7 @@
<script>
export default {
props: {
value: {
io: {
type: null,
required: true,
},
@@ -19,6 +19,26 @@ export default {
type: String,
required: true,
}
},
computed: {
value: {
get() {
return this.io.value;
},
set(val) {
if (this.type === 'Number') {
this.io.value = parseFloat(val)
} else {
this.io.value = val;
}
}
}
},
methods: {
onChange($event) {
console.log('change', $event);
this.value = $event.target.value;
}
}
}
</script>

View File

@@ -4,22 +4,34 @@ import SelectValue from './Select.vue';
export default {
props: {
type: {
io: {
type: Object,
required: true,
},
direction: {
type: String,
required: true,
},
value: {
type: null,
required: true,
}
},
render(h) {
return h(
this.type === 'Image' ? ImageValue :
this.type === 'Number' ? StringValue :
this.type === 'Color' ? StringValue :
Array.isArray(this.type) ? SelectValue :
this.io.type === 'Image' ? ImageValue :
this.io.type === 'Number' ? StringValue :
this.io.type === 'Color' ? StringValue :
this.io.definition.enum ? SelectValue :
null,
{props: {value: this.value, type: this.type}})
{
props: {
io: this.io,
value: this.io.value,
type: this.io.type,
direction: this.direction
},
on: {
change: (value) => {
this.$emit('change', value);
}
}
})
}
};