basic ui, some filters

This commit is contained in:
2019-01-21 20:39:56 +01:00
parent e76aafc35a
commit 6394f2a869
30 changed files with 974 additions and 111 deletions

8
package-lock.json generated
View File

@@ -5095,6 +5095,11 @@
"integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==",
"dev": true "dev": true
}, },
"interactjs": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/interactjs/-/interactjs-1.3.4.tgz",
"integrity": "sha512-AQ2CdPEyHqiEEQ1FFgMBj79UEsU1+rUwSXuhOkflvB65p4iECft28SN/PvhD/Y9OtNge8aH1qTibjAi+RXQMqQ=="
},
"internal-ip": { "internal-ip": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/internal-ip/-/internal-ip-3.0.1.tgz", "resolved": "https://registry.npmjs.org/internal-ip/-/internal-ip-3.0.1.tgz",
@@ -9851,8 +9856,7 @@
"uuid": { "uuid": {
"version": "3.3.2", "version": "3.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz",
"integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==", "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA=="
"dev": true
}, },
"v8-compile-cache": { "v8-compile-cache": {
"version": "2.0.2", "version": "2.0.2",

View File

@@ -16,21 +16,24 @@
"@types/jest": "^23.3.10", "@types/jest": "^23.3.10",
"@types/webpack": "^4.4.22", "@types/webpack": "^4.4.22",
"babel-jest": "^23.6.0", "babel-jest": "^23.6.0",
"canvas": "^2.2.0",
"jest": "^23.6.0",
"webpack": "^4.28.3",
"webpack-cli": "^3.1.2",
"webpack-dev-server": "^3.1.14",
"babel-loader": "^8.0.4", "babel-loader": "^8.0.4",
"babel-polyfill": "^6.26.0", "babel-polyfill": "^6.26.0",
"canvas": "^2.2.0",
"css-loader": "^1.0.1", "css-loader": "^1.0.1",
"file-loader": "^2.0.0", "file-loader": "^2.0.0",
"friendly-errors-webpack-plugin": "^1.7.0", "friendly-errors-webpack-plugin": "^1.7.0",
"html-webpack-plugin": "^3.2.0", "html-webpack-plugin": "^3.2.0",
"jest": "^23.6.0",
"vue-loader": "^15.4.2", "vue-loader": "^15.4.2",
"vue-template-compiler": "^2.5.17" "vue-style-loader": "^4.1.2",
"vue-template-compiler": "^2.5.17",
"webpack": "^4.28.3",
"webpack-cli": "^3.1.2",
"webpack-dev-server": "^3.1.14"
}, },
"dependencies": { "dependencies": {
"interactjs": "^1.3.4",
"uuid": "^3.3.2",
"vue": "^2.5.21", "vue": "^2.5.21",
"webgl-utils": "^1.0.1" "webgl-utils": "^1.0.1"
} }

View File

@@ -1,10 +1,10 @@
import Node from './Node'; import Node from './Node';
import {createCanvas, mediaSize, paintToCanvas} from './canvas'; import {createCanvas, mediaSize, paintToCanvas} from './canvas';
export default class Resize extends Node { export default class Compose extends Node {
constructor(options) { constructor(options) {
super({ super('Compose', {
fg: 'Image', fg: 'Image',
fgX: 'Number', fgX: 'Number',
fgY: 'Number', fgY: 'Number',

View File

@@ -2,7 +2,7 @@ import Node from './Node';
export default class ImageIdentity extends Node { export default class ImageIdentity extends Node {
constructor() { constructor() {
super({ super('ImageIdentity', {
image: 'Image', image: 'Image',
}, { }, {
image: 'Image', image: 'Image',

View File

@@ -1,10 +1,13 @@
import {Inputs, Outputs} from './io.js'; import {Inputs, Outputs} from './io.js';
import uuidv4 from 'uuid/v4';
export default class Node { export default class Node {
constructor(inputNames, outputNames) { constructor(name, inputNames, outputNames) {
this.__in = new Inputs(inputNames); this.name = name;
this.__out = new Outputs(outputNames); this.id = uuidv4();
this.__in = new Inputs(inputNames, this);
this.__out = new Outputs(outputNames, this);
this.__in.update = (name) => this.__update([name]); this.__in.update = (name) => this.__update([name]);
} }
@@ -25,5 +28,33 @@ export default class Node {
get out() { get out() {
return this.__out; return this.__out;
} }
serialize() {
return {
id: this.id,
name: this.name,
options: {
in: this.in.serialize(),
},
}
}
deserialize({id, options}) {
this.id = id || uuidv4();
}
reconnect({options}, outputs) {
console.log('outputs', outputs)
for (let name of Object.keys(options.in)) {
const {value, output} = options.in[name];
if (value) {
this.in[name].value = value;
}
if (output) {
console.log('connecting', this.in[name].id, outputs[output])
this.in[name].connect(outputs[output]);
}
}
}
}; };

View File

@@ -4,7 +4,7 @@ import {createCanvas, mediaSize, paintToCanvas} from './canvas';
export default class Resize extends Node { export default class Resize extends Node {
constructor(options) { constructor(options) {
super({ super('Resize', {
image: 'Image', image: 'Image',
width: 'Number', width: 'Number',
height: 'Number', height: 'Number',
@@ -24,6 +24,9 @@ export default class Resize extends Node {
__update() { __update() {
const media = this.__in.image.value; const media = this.__in.image.value;
if (!media) {
return;
}
const canvas = createCanvas(this.width, this.height); const canvas = createCanvas(this.width, this.height);
const {width: srcWidth, height: srcHeight} = mediaSize(media); const {width: srcWidth, height: srcHeight} = mediaSize(media);

View File

@@ -4,7 +4,7 @@ import {createCanvas} from './canvas';
export default class ToBlob extends Node { export default class ToBlob extends Node {
constructor(options) { constructor(options) {
super({ super('ToBlob', {
image: 'Image', image: 'Image',
}, { }, {
image: 'Image', image: 'Image',

View File

@@ -4,7 +4,7 @@ import WebGL from './WebGl';
export default class BrightnessContrast extends WebGL { export default class BrightnessContrast extends WebGL {
constructor() { constructor() {
super({ super('BrightnessContrast', {
brightness: 'Number', brightness: 'Number',
contrast: 'Number', contrast: 'Number',
}) })

View File

@@ -0,0 +1,106 @@
import WebGL from './WebGl';
const hexColorTOvec3 = (val) => {
const match = val.match(/#?([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})/);
if (match) {
return [parseInt(match[1], 16) / 255, parseInt(match[2], 16) / 255, parseInt(match[3], 16) / 255];
} else {
return null;
}
};
export default class GreenScreen extends WebGL {
constructor() {
super('GreenScreen', {
balance: 'Number',
screen: 'Color',
screenWeight: 'Number',
clipBlack: 'Number',
clipWhite: 'Number',
})
}
get frag() {
return `
precision mediump float;
// our texture
uniform sampler2D u_image;
// the texCoords passed in from the vertex shader.
varying vec2 v_texCoord;
uniform float balance;
uniform vec3 screen;
uniform float clipBlack;
uniform float clipWhite;
uniform float screenWeight;
void main() {
float pixelSat, secondaryComponents;
vec4 sourcePixel = texture2D(u_image, v_texCoord);
float fmin = min(min(sourcePixel.r, sourcePixel.g), sourcePixel.b);
float fmax = max(max(sourcePixel.r, sourcePixel.g), sourcePixel.b);
vec3 pixelPrimary = step(fmax, sourcePixel.rgb);
secondaryComponents = dot(1.0 - pixelPrimary, sourcePixel.rgb);
// luminance = fmax
float screenFmin = min(min(screen.r, screen.g), screen.b); //Min. value of RGB
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);
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);
/*
Semi-transparent pixel if the primary component matches but if saturation is less
than that of screen color. Otherwise totally transparent
*/
float alpha = max(0.0, 1.0 - pixelSat / screenSat);
alpha = smoothstep(clipBlack, clipWhite, alpha);
vec4 semiTransparentPixel = vec4((sourcePixel.rgb - (1.0 - alpha) * screen.rgb * screenWeight) / max(0.00001, alpha), alpha);
vec4 pixel = mix(semiTransparentPixel, sourcePixel, solid);
// vec4 pixel = vec4(sourcePixel.rgb, (1.0 - alpha));
gl_FragColor = vec4(pixel.rgb * pixel.a, pixel.a);
//gl_FragColor = vec4(min(1.0, max(pixel.r, 0.0)), min(1.0, max(pixel.g, 0.0)), min(1.0, max(pixel.b, 0.0)), min(1.0, max(pixel.a, 0.0)));
}
`
}
get clipBlack() {
return (this.in.clipBlack.value || 0);
}
get clipWhite() {
return (this.in.clipWhite.value || 0);
}
get screenWeight() {
return (this.in.screenWeight.value || 1);
}
get screen() {
return hexColorTOvec3(this.in.screen.value || '#00FF00');
}
get balance() {
return (this.in.balance.value || 0.5);
}
_setParams(gl, program) {
gl.uniform3f(gl.getUniformLocation(program, 'screen'), ...this.screen);
gl.uniform1f(gl.getUniformLocation(program, 'balance'), this.balance);
gl.uniform1f(gl.getUniformLocation(program, 'clipBlack'), this.clipBlack);
gl.uniform1f(gl.getUniformLocation(program, 'clipWhite'), this.clipWhite);
gl.uniform1f(gl.getUniformLocation(program, 'screenWeight'), this.screenWeight);
}
}

View File

@@ -1,10 +1,10 @@
import WebGL from './WebGl'; import WebGL from './WebGl';
export default class BrightnessContrast extends WebGL { export default class HSV extends WebGL {
constructor() { constructor() {
super({ super('HSV', {
hue: 'Number', hue: 'Number',
saturation: 'Number', saturation: 'Number',
value: 'Number', value: 'Number',

View File

@@ -1,10 +1,10 @@
import WebGL from './WebGl'; import WebGL from './WebGl';
export default class BrightnessContrast extends WebGL { export default class Sepia extends WebGL {
constructor() { constructor() {
super({ super('Sepia', {
amount: 'Number', amount: 'Number',
}) })
} }

View File

@@ -6,8 +6,8 @@ console.log(webglUtils);
export default class WebGL extends Node { export default class WebGL extends Node {
constructor(inputs={}) { constructor(name, inputs={}) {
super(Object.assign({ super(name, Object.assign({
image: 'Image', image: 'Image',
}, inputs), { }, inputs), {
image: 'Image' image: 'Image'
@@ -86,7 +86,7 @@ export default class WebGL extends Node {
* *
* @param {WebGLRenderingContext} gl * @param {WebGLRenderingContext} gl
*/ */
program(gl) { compileProgram(gl) {
const program = gl.createProgram(); const program = gl.createProgram();
gl.attachShader(program, this.vertShader(gl)); gl.attachShader(program, this.vertShader(gl));
@@ -115,10 +115,7 @@ export default class WebGL extends Node {
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
if (this.image) {
// Upload the image into the texture.
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this.image);
}
return texture; return texture;
} }
@@ -126,22 +123,46 @@ export default class WebGL extends Node {
} }
setup() {
if (!this.canvas) {
this.program = null;
this.canvas = createCanvas(0, 0);
}
const gl = this.canvas.getContext('webgl');
if (!this.program) {
this.program = this.compileProgram(gl);
gl.useProgram(this.program);
this.createTexture(gl);
}
}
__update() { __update() {
const image = this.in.image.value; const image = this.in.image.value;
if (!image) return; if (!image) return;
const {width, height} = mediaSize(image); const {width, height} = mediaSize(image);
const canvas = createCanvas(width, height); this.setup();
const canvas = this.canvas;
canvas.width = width;
canvas.height = height;
const gl = canvas.getContext('webgl'); const gl = canvas.getContext('webgl');
const program = this.program(gl); const program = this.program;
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
const positionLocation = gl.getAttribLocation(program, "a_position"); const positionLocation = gl.getAttribLocation(program, "a_position");
const texcoordLocation = gl.getAttribLocation(program, "a_texCoord"); const texcoordLocation = gl.getAttribLocation(program, "a_texCoord");
const resolutionLocation = gl.getUniformLocation(program, "u_resolution"); const resolutionLocation = gl.getUniformLocation(program, "u_resolution");
const positionBuffer = gl.createBuffer(); if (this.image) {
// Upload the image into the texture.
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this.image);
}
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
this.setRectangle(gl, 0, 0, width, height);
const texcoordBuffer = gl.createBuffer(); const texcoordBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer); gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
@@ -153,17 +174,12 @@ export default class WebGL extends Node {
1.0, 1.0, 1.0, 1.0,
]), gl.STATIC_DRAW); ]), gl.STATIC_DRAW);
this.createTexture(gl);
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(program);
gl.enableVertexAttribArray(positionLocation); gl.enableVertexAttribArray(positionLocation);
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
this.setRectangle(gl, 0, 0, width, height);
// Tell the position attribute how to get data out of positionBuffer (ARRAY_BUFFER) // Tell the position attribute how to get data out of positionBuffer (ARRAY_BUFFER)
var size = 2; // 2 components per iteration var size = 2; // 2 components per iteration
@@ -195,10 +211,7 @@ export default class WebGL extends Node {
this._setParams(gl, program); this._setParams(gl, program);
// Draw the rectangle. // Draw the rectangle.
var primitiveType = gl.TRIANGLES; gl.drawArrays(gl.TRIANGLES, 0, 6);
var offset = 0;
var count = 6;
gl.drawArrays(primitiveType, offset, count);
this.out.image.value = canvas; this.out.image.value = canvas;
} }

41
src/nodes/Webcam.js Normal file
View File

@@ -0,0 +1,41 @@
import Node from './Node';
export default class ToBlob extends Node {
constructor(options) {
super('Webcam', {}, {
image: 'Image',
});
this.start();
}
async start() {
const devices = (await navigator.mediaDevices.enumerateDevices()).filter((d) => d.kind === 'videoinput')
const last = (ary) => ary[ary.length - 1];
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 },
deviceId: last(devices).deviceId
},
});
console.log(stream)
/** @type {HTMLVideoElement} */
const video = document.createElement('video');
video.playsinline = true;
video.srcObject = stream;
video.play();
let lastFrameTime = null;
const feedLoop = () => {
if (lastFrameTime !== video.currentTime) {
lastFrameTime = video.currentTime;
this.out.image.value = video;
}
requestAnimationFrame(feedLoop);
};
feedLoop();
}
}

View File

@@ -1,3 +1,4 @@
export {default as Webcam} from './Webcam';
export {default as Resize} from './Resize'; export {default as Resize} from './Resize';
export {default as ImageIdentity} from './ImageIdentity'; export {default as ImageIdentity} from './ImageIdentity';
export {default as ToBlob} from './ToBlob'; export {default as ToBlob} from './ToBlob';
@@ -6,3 +7,4 @@ export {default as WebGL} from './WebGL/WebGl';
export {default as BrightnessContrast} from './WebGL/BrightnessContrast'; export {default as BrightnessContrast} from './WebGL/BrightnessContrast';
export {default as HSV} from './WebGL/HSV'; export {default as HSV} from './WebGL/HSV';
export {default as Sepia} from './WebGL/Sepia'; export {default as Sepia} from './WebGL/Sepia';
export {default as GreenScreen} from './WebGL/Greenscreen';

View File

@@ -15,6 +15,10 @@ export class Input {
} }
} }
get id() {
return `${this.__owner.id}-${this.__name}`;
}
get name() { get name() {
return this.__name; return this.__name;
} }
@@ -29,12 +33,20 @@ export class Input {
} }
connect(output) { connect(output) {
this.disconnect();
this.__output = output this.__output = output
this.__output.onchange(this.__onchangelistener); this.__output.onchange(this.__onchangelistener);
} }
disconnect(output) { disconnect(output) {
this.__output.offchage(this.__onchangelistener); if (this.__output) {
this.__output.offchange(this.__onchangelistener);
this.__output = null;
}
}
get output() {
return this.__output;
} }
__notifyListeners() { __notifyListeners() {
@@ -51,6 +63,13 @@ export class Input {
this.__listeners = this.__listeners this.__listeners = this.__listeners
.filter((l) => l !== listener); .filter((l) => l !== listener);
} }
serialize() {
return {
value: this.__output ? null : this.__value,
output: this.__output ? this.__output.id: null,
}
}
} }
export class Output { export class Output {
@@ -62,6 +81,10 @@ export class Output {
this.__listeners = []; this.__listeners = [];
} }
get id() {
return `${this.__owner.id}-${this.__name}`;
}
get name() { get name() {
return this.__name; return this.__name;
} }
@@ -94,8 +117,9 @@ export class Output {
export class Inputs { export class Inputs {
constructor(variables) { constructor(variables, owner) {
this.__values = {}; this.__values = {};
this.__owner = owner;
this.variables = variables; this.variables = variables;
for (let name of Object.keys(this.variables)) { for (let name of Object.keys(this.variables)) {
@@ -108,14 +132,27 @@ export class Inputs {
} }
} }
get id() {
return `${this.__owner.id}-in`;
}
update(changed) { update(changed) {
} }
serialize() {
const res = {};
for (let name of Object.keys(this.variables)) {
res[name] = this.__values[name].serialize();
}
return res;
}
} }
export class Outputs { export class Outputs {
constructor(variables) { constructor(variables, owner) {
this.__values = {}; this.__values = {};
this.__owner = owner;
this.variables = variables; this.variables = variables;
for (let name of Object.keys(this.variables)) { for (let name of Object.keys(this.variables)) {
@@ -127,4 +164,12 @@ export class Outputs {
}) })
} }
} }
get id() {
return `${this.__owner.id}-out`;
}
serialize() {
return null;
}
} }

77
ui/Components/App.vue Normal file
View File

@@ -0,0 +1,77 @@
<template>
<div class="app">
<div class="app__toolbar">
<select v-model="selectedSaveSlot">
<option
v-for="save in saveSlots"
:key="save"
:value="save"
>{{save}}</option>
</select>
<button @click="save">Save</button>
<button @click="load">Load</button>
</div>
<Graph
class="app__scene"
:graph="graph"
/>
</div>
</template>
<script>
import Graph from './Graph.vue';
import {load, save} from '../persistent';
export default {
components: {
Graph,
},
data() {
return {
graph: [],
currentSelectedSaveSlot: localStorage.getItem('IFP__SELECTED_SAVE_SLOT') || 'FIRST',
saveSlots: [
'FIRST',
'SECOND',
'THIRD',
],
}
},
computed: {
selectedSaveSlot: {
get() {
return this.currentSelectedSaveSlot;
},
set(val) {
this.currentSelectedSaveSlot = val;
localStorage.setItem('IFP__SELECTED_SAVE_SLOT', val);
}
},
},
methods: {
load() {
this.graph = load(this.selectedSaveSlot);
},
save() {
save(this.selectedSaveSlot, this.graph);
}
},
mounted() {
this.load();
}
}
</script>
<style>
.app {
position: fixed;
left: 0;
bottom: 0;
top: 0;
right: 0;
}
.app__toolbar {
padding: 10px;
text-align: right;
}
</style>

View File

@@ -0,0 +1,68 @@
<template>
<ul
v-if="open"
class="context-menu"
:style="{
'--left': `${currentPosition.x}px`,
'--top': `${currentPosition.y}px`,
}"
>
<li
v-for="item in items"
:key="item.label"
class="context-menu__item"
@click="select(item)"
>
{{ item.label }}
</li>
</ul>
</template>
<script>
export default {
props: {
position: {
type: null,
required: true,
},
open: {
type: Boolean,
default: false,
},
items: {
type: Array,
required: true,
}
},
computed: {
currentPosition() {
return Object.assign({x: 0, y: 0}, this.position);
},
},
methods: {
select(item) {
this.$emit('selected', {
x: this.currentPosition.x,
y: this.currentPosition.y,
item,
});
}
},
}
</script>
<style>
.context-menu {
--left: 0;
--top: 0;
position: absolute;
left: var(--left);
top: var(--top);
background-color: white;
padding: 5px 10px;
border-radius: 5px;
list-style: none;
}
.context-menu__item {
border-bottom: 1px solid
}
</style>

View File

@@ -0,0 +1,24 @@
import interact from 'interactjs';
export default {
abstract: true,
render() {
try {
return this.$slots.default[0];
} catch (e) {
throw new Error('Exactly one child component needed.');
}
return null;
},
mounted () {
const el = this.$slots.default[0].elm;
this.$nextTick(() => {
interact(el)
.draggable({
onmove: (event) => {
this.$emit('move', event);
}
})
})
}
}

View File

@@ -1,17 +1,47 @@
<template> <template>
<div> <div class="graph">
<Node <canvas
v-if="boundingRect"
class="graph__bg"
ref="bg"
:width="boundingRect.width"
:height="boundingRect.height"
@click="showContextMenu"
></canvas>
<Draggable
v-for="(node, index) in graph" v-for="(node, index) in graph"
:key="index" :key="index"
:node="node" @move="updateNodePosition(node, $event)"
>
<Node
class="graph__node"
:style="{transform: `translate(${node.x}px, ${node.y}px)`}"
:node="node.node"
ref="node"
:selectedInput="selectedInput"
:selectedOutput="selectedOutput"
@outputSelected="selectedOutput = $event"
@inputSelected="selectedInput = $event"
/>
</Draggable>
<ContextMenu
:open="!!contextMenuPosition"
:position="contextMenuPosition"
:items="availableNodes"
@selected="addNode"
/> />
</div> </div>
</template> </template>
<script> <script>
import * as nodes from '../../src/nodes';
import Node from './Node.vue'; import Node from './Node.vue';
import Draggable from './Drag/Draggable.js';
import ContextMenu from './ContextMenu.vue';
export default { export default {
components: { components: {
ContextMenu,
Draggable,
Node, Node,
}, },
props: { props: {
@@ -19,6 +49,132 @@ export default {
type: Array, type: Array,
required: true, required: true,
} }
},
data() {
return {
selectedOutput: null,
selectedInput: null,
boundingRect: null,
contextMenuPosition: null,
}
},
watch: {
selectedOutput() {
this.tryConnectIO();
},
selectedInput() {
this.tryConnectIO();
},
graph: {
deep: true,
handler() {
this.drawConnections();
}
}
},
methods: {
showContextMenu(event) {
const {offsetX, offsetY} = event;
if (this.contextMenuPosition) {
this.contextMenuPosition = null;
} else {
this.contextMenuPosition = {
x: offsetX,
y: offsetY,
};
}
},
updateNodePosition(node, {clientX, clientY, x0, y0}) {
const x = clientX - this.$el.offsetLeft;
const y = clientY - this.$el.offsetTop;
console.log('updateNodePosition', x, y)
node.x = x;
node.y = y;
},
tryConnectIO() {
if (this.selectedInput && this.selectedOutput) {
this.selectedInput.connect(this.selectedOutput);
this.selectedInput = null;
this.selectedOutput = null;
}
},
recomputeSize() {
this.boundingRect = this.$el.getBoundingClientRect();
},
drawConnections() {
try {
const flattenObjectArray = (ary) =>
ary.reduce((acc, nxt) => Object.assign(acc, nxt), {});
const inputs = flattenObjectArray(this.$refs.node
.map((vm) => vm.inputPositions()));
const outputs = flattenObjectArray(this.$refs.node
.map((vm) => vm.outputPositions()));
const bg = this.$refs.bg;
const ctx = bg.getContext('2d');
ctx.clearRect(0, 0, bg.width, bg.height);
ctx.strokeStyle = "#eaa11a";
ctx.lineWidth = 3;
for (let input of Object.values(inputs)) {
if (!input.output) continue;
const output = outputs[input.output.id];
ctx.beginPath();
ctx.moveTo(input.rect.left, input.rect.top);
const halfWay = {x: (input.rect.left + output.rect.right)/2, y: (input.rect.y + output.rect.y)/2};
ctx.bezierCurveTo(
Math.min(halfWay.x, input.rect.left - Math.max(Math.abs(halfWay.x - input.rect.left), 50)), input.rect.top,
Math.max(halfWay.x, output.rect.right + Math.max(Math.abs(halfWay.x - output.rect.right), 50)), output.rect.top,
// Math.min(halfWay.x, output.rect.right - (halfWay.x - output.rect.right)), output.rect.top,
//halfWay.x, output.rect.top,
output.rect.right, output.rect.top
);
ctx.stroke();
}
} catch (err) {
console.warn('drawConnection', err);
}
},
addNode({x, y, item: {node}}) {
this.graph.push({
x, y,
node: new node(),
});
this.contextMenuPosition = null;
}
},
computed: {
availableNodes() {
return Object.keys(nodes)
.map((name) => ({
label: name,
node: nodes[name],
}))
}
},
mounted() {
this.$nextTick(() => {
this.recomputeSize();
this.drawConnections();
})
} }
} }
</script> </script>
<style>
.graph {
position: relative;
width: 100vw;
height: 100vh;
background: linear-gradient(#333333, #151515);
}
.graph__node {
position: absolute;
}
.graph__bg {
position: absolute;
top: 0;
left: 0;
}
</style>

34
ui/Components/Input.vue Normal file
View File

@@ -0,0 +1,34 @@
<template>
<canvas></canvas>
</template>
<script>
export const getStream = (callback) => {
navigator.getUserMedia({video: true}, (stream) => {
console.log(stream)
/** @type {HTMLVideoElement} */
const video = document.getElementById('Input');
video.srcObject = stream;
video.play();
let lastFrameTime = null;
const feedLoop = () => {
if (lastFrameTime !== video.currentTime) {
lastFrameTime = video.currentTime;
callback(video);
}
requestAnimationFrame(feedLoop);
};
feedLoop();
}, console.error.bind(console))
}
getStream((video) => resize.in.image.value = video);
export default {
mounted() {
}
}
</script>

View File

@@ -1,25 +1,52 @@
<template> <template>
<div <div
style=" class="node"
border: 2px solid black;
margin: 5px;
padding: 2px;
display: flex;
flex-direction: row;
"
> >
<div style="flex: 1"> <div style="flex: 1" class="node__vars node__vars--input">
<strong>Inputs:</strong> <div
<div v-for="(type, name) in node.in.variables" :key="name"> v-for="(type, name) in node.in.variables"
<div>{{ name }} <i>{{typeName(type)}}</i></div> :key="name"
<Value :type="type" :value="node.in[name]" /> :ref="`in-${name}`"
class="node__var node-var node-var--input"
:class="{'node-var--selected': node.in[name] === selectedInput }"
>
<div
class="node-var__label"
@click="$emit('inputSelected', node.in[name])"
>
{{ name }} <!--<i>{{typeName(type)}}</i> -->
<span
v-if="node.in[name].output"
@click.stop="node.in[name].disconnect()"
>&times;</span>
</div>
<Value
:type="type"
:value="node.in[name]"
class="node-var__value"
/>
</div> </div>
</div> </div>
<div style="flex: 1"> <div style="flex: 1">
<strong>Outputs:</strong> <div>
<div v-for="(type, name) in node.out.variables" :key="name"> {{node.name}}
<div>{{ name }} <i>{{typeName(type)}}</i></div> </div>
<Value :type="type" :value="node.out[name]" /> <div
v-for="(type, name) in node.out.variables"
:key="name"
:ref="`out-${name}`"
class="node__var node-var node-var--output"
:class="{'node-var--selected': node.out[name] === selectedOutput }"
>
<div
class="node-var__label"
@click="$emit('outputSelected', node.out[name])"
>{{ name }} <!--<i>{{typeName(type)}}</i>--></div>
<Value
:type="type"
:value="node.out[name]"
class="node-var__value"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -32,6 +59,14 @@ export default {
Value, Value,
}, },
props: { props: {
selectedInput: {
type: Object,
default: null
},
selectedOutput: {
type: Object,
default: null
},
node: { node: {
type: Object, type: Object,
required: true, required: true,
@@ -40,7 +75,73 @@ export default {
methods: { methods: {
typeName(type) { typeName(type) {
return Array.isArray(type) ? 'Option' : type; return Array.isArray(type) ? 'Option' : type;
} },
outputPositions() {
const outs = {};
for (let out in this.node.out.variables) {
outs[this.node.out[out].id] = {
rect: this.$refs[`out-${out}`][0].getBoundingClientRect(),
}
}
return outs;
},
inputPositions() {
const ins = {};
for (let i in this.node.in.variables) {
ins[this.node.in[i].id] = {
output: this.node.in[i].output,
rect: this.$refs[`in-${i}`][0].getBoundingClientRect(),
}
}
return ins;
},
} }
} }
</script> </script>
<style>
.node-var {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
}
.node-var__label {
width: 5em;
display: inline-block;
}
.node-var__value {
width: 5em;
display: inline-block;
}
.node-var--selected {
font-weight: bold;
}
.node-var--input {
border-left: 5px solid green;
padding-left: 4px;
}
.node-var--output {
border-right: 5px solid orange;
padding-right: 4px;
flex-direction: row-reverse;
}
.node {
margin: 5px;
padding: 5px 0;
display: inline-flex;
flex-direction: row;
background-color: #EEE;
border-radius: 5px;
box-shadow: 1px 1px 5px rgba(0,0,0,0.35);
}
.node__var {
margin-bottom: 2px
}
</style>

View File

@@ -1,11 +1,13 @@
<template> <template>
<canvas <div
style=" class="image-value"
width: 150px; @click="download"
box-shadow: 0 0 6px; >
" <canvas class="image-value__thumb" ref="thumb" />
@click="isRunning = !isRunning" <canvas class="image-value__preview" ref="preview" />
/> </div>
</template> </template>
<script> <script>
import {paintToCanvas, mediaSize} from '../../../src/nodes/canvas.js' import {paintToCanvas, mediaSize} from '../../../src/nodes/canvas.js'
@@ -26,7 +28,7 @@ export default {
}, },
data() { data() {
return { return {
isRunning: false, isRunning: true,
}; };
}, },
mounted() { mounted() {
@@ -35,29 +37,75 @@ export default {
this.value.onchange(this.redraw); this.value.onchange(this.redraw);
}) })
}, },
watch: {
isRunning: {
handler() {
this.redraw();
}
}
},
methods: { methods: {
redrawCanvas(canvas) {
const value = this.value.value;
/** @type {HTMLCanvasElement} */
const ctx = canvas.getContext('2d');
if (!value) {
canvas.width = 1;
canvas.height = 1;
ctx.clearRect(0, 0, 1, 1);
} else {
const {width, height} = mediaSize(value);
canvas.width = width;
canvas.height = height;
paintToCanvas(canvas, value, {
top: 0,
left: 0,
width, height,
});
}
},
redraw() { redraw() {
if (this.isRunning) { if (this.isRunning) {
const value = this.value.value; this.redrawCanvas(this.$refs.thumb);
/** @type {HTMLCanvasElement} */ this.redrawCanvas(this.$refs.preview)
const canvas = this.$el;
const ctx = canvas.getContext('2d');
if (!value) {
canvas.width = 1;
canvas.height = 1;
ctx.clearRect(0, 0, 1, 1);
} else {
const {width, height} = mediaSize(value);
canvas.width = width;
canvas.height = height;
paintToCanvas(canvas, value, {
top: 0,
left: 0,
width, height,
});
}
} }
},
download() {
const a = document.createElement('a');
a.href = this.$refs.preview.toDataURL();
a.download = 'sample.png';
a.click();
console.log('download');
} }
} }
} }
</script> </script>
<style>
.image-value {
position: relative;
}
.image-value__thumb {
width: 50px;
height: 50px;
object-fit: contain;
background-color: black;
box-shadow: 0 0 6px;
}
.image-value__preview {
display: none;
position: absolute;
top: 0;
left: 0;
width: 30vw;
height: 30vh;
object-fit: contain;
background-color: black;
pointer-events: none;
z-index: 1;
}
.image-value__thumb:hover ~ .image-value__preview {
display: block;
}
</style>

View File

@@ -11,7 +11,7 @@
export default { export default {
props: { props: {
type: { type: {
type: Array, type: null,
required: true, required: true,
}, },
value: { value: {

View File

@@ -1,5 +1,12 @@
<template> <template>
<input v-model="value.value"> <input
v-model="value.value"
:type="
type === 'Number' ? 'number' :
type === 'Color' ? 'color' :
'text'
"
>
</template> </template>
<script> <script>
export default { export default {
@@ -7,6 +14,10 @@ export default {
value: { value: {
type: null, type: null,
required: true, required: true,
},
type: {
type: String,
required: true,
} }
} }
} }

View File

@@ -17,6 +17,7 @@ export default {
return h( return h(
this.type === 'Image' ? ImageValue : this.type === 'Image' ? ImageValue :
this.type === 'Number' ? StringValue : this.type === 'Number' ? StringValue :
this.type === 'Color' ? StringValue :
Array.isArray(this.type) ? SelectValue : Array.isArray(this.type) ? SelectValue :
null, null,
{props: {value: this.value, type: this.type}}) {props: {value: this.value, type: this.type}})

44
ui/defaultGraph.js Normal file
View File

@@ -0,0 +1,44 @@
export default [
{
"node": {
name: "Webcam"
},
"x": 0,
"y": 0,
},
{
"node": {
"name": "Resize"
},
"x": 0,
"y": 0
},
{
"node": {
"name": "BrightnessContrast"
},
"x": 0,
"y": 0
},
{
"node": {
"name": "HSV"
},
"x": 0,
"y": 0
},
{
"node": {
"name": "Sepia"
},
"x": 0,
"y": 0
},
{
"node": {
"name": "Compose"
},
"x": 0,
"y": 0
}
];

View File

@@ -1,6 +1,6 @@
import * as nodes from '../src/nodes'; import * as nodes from '../src/nodes';
import {sampleImage, getStream} from './helpers'; import {sampleImage, getStream} from './helpers';
/*
const resize = new nodes.Resize({ const resize = new nodes.Resize({
width: 1000, width: 1000,
height: 1000, height: 1000,
@@ -26,11 +26,4 @@ sepia.in.image.connect(hsv.out.image);
compose.in.fg.connect(sepia.out.image); compose.in.fg.connect(sepia.out.image);
getStream((video) => resize.in.image.value = video); getStream((video) => resize.in.image.value = video);
*/
export default [
resize,
brightnessContrast,
hsv,
sepia,
compose,
];

View File

@@ -1,11 +1,9 @@
import Vue from 'vue'; import Vue from 'vue';
import Graph from './Components/Graph.vue'; import App from './Components/App.vue';
import graph from './graph';
new Vue({ new Vue({
el: '#app', el: '#app',
render(h) { render(h) {
return h(Graph, {props: {graph}}) return h(App)
} },
}) })

60
ui/persistent.js Normal file
View File

@@ -0,0 +1,60 @@
import * as nodes from '../src/nodes';
import defaultValues from './defaultGraph';
export const createNode = ({name, options, id}) => {
try {
const node = new nodes[name](options);
node.deserialize({id, options});
return node;
} catch (err) {
console.error('Failed to create node', name, err)
}
}
export const createNodes = (nodes) => {
nodes = nodes.map(({node, x, y}) => {
const nodeInstance = createNode(node);
return ({
node: nodeInstance,
x, y,
connect(outputs) {
try {
nodeInstance.reconnect(node, outputs);
} catch(err) {
console.error('Can\'t recoonect', nodeInstance, err);
}
}
});
}).filter(({node}) => node);
const outputsById = nodes
.map(({node}) => Object.values(node.out.__values))
.reduce((acc, nxt) => acc.concat(nxt), [])
.reduce((acc, nxt) => {
acc[nxt.id] = nxt;
return acc;
}, {});
return nodes.map(({node, x, y, connect}) => {
connect(outputsById);
return {node,x,y};
})
}
export const serializeNodes = (nodes) =>
nodes.map(({node, x, y}) => ({
node: node.serialize(),
x, y,
}));
export const load = (name) => {
try {
return createNodes(JSON.parse(localStorage.getItem(`IFP_GRAPH_${name}`)) || defaultValues);
} catch (err) {
return createNodes(defaultValues);
}
};
export const save = (name, graph) => localStorage.setItem(`IFP_GRAPH_${name}`, JSON.stringify(serializeNodes(graph)));

View File

@@ -49,7 +49,7 @@ const config = {
{ {
test: /\.css$/, test: /\.css$/,
use: [ use: [
{loader: 'style-loader'}, {loader: 'vue-style-loader'},
{loader: 'css-loader'}, {loader: 'css-loader'},
], ],
} }