Skip to content

Commit

Permalink
feat(grabCanvas): Adds grab-canvas as a plugin (#612)
Browse files Browse the repository at this point in the history
* feat(grabcanvas): adds grabCanvas plugin

also adds the plugin panel and adds some plugin lifecycle functions

#366

* feat(pluginprops): adds plugin props

splits the Control component out into ModuleControl and PluginControl to allow code re-use. Adds
store methods for plugin props.

re #366

* fix: removes unnecessary gl-stack for plugins

* feat: adds infoview directive for plugins panel

* feat: adds searchterms directive

* fix(grab-canvas): Clear mappingContext on every frame

* refactor(grab-canvas): Set default size to 7x7

* refactor(grab-canvas): Proper reconnect and restructure of code

Co-authored-by: Sam Wray <[email protected]>
TimPietrusky and 2xAA authored Jul 1, 2021
1 parent f9f2b90 commit d7ca887
Showing 12 changed files with 626 additions and 60 deletions.
8 changes: 7 additions & 1 deletion src/App.vue
Original file line number Diff line number Diff line change
@@ -66,6 +66,10 @@
<NDIConfig />
</gl-component>
</gl-stack>

<gl-component title="Plugins" :closable="false">
<Plugins />
</gl-component>
</gl-stack>

<gl-stack title="Preview Stack">
@@ -101,6 +105,7 @@ import InfoView from "@/components/InfoView";
import Search from "@/components/Search";
import FrameRateDialog from "@/components/dialogs/FrameRateDialog";
import ErrorWatcher from "@/components/ErrorWatcher";
import Plugins from "@/components/Plugins";
import getNextName from "@/application/utils/get-next-name";
import constants from "@/application/constants";
@@ -125,7 +130,8 @@ export default {
ModuleInspector,
Search,
FrameRateDialog,
ErrorWatcher
ErrorWatcher,
Plugins
},
data() {
185 changes: 185 additions & 0 deletions src/application/plugins/grab-canvas.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
const mappingCanvas = new OffscreenCanvas(1, 1);
mappingCanvas.title = "mappingCanvas";

let timeout = 0;
let connection = undefined;
let outputContext = null;
let reconnect = false;

const mappingContext = mappingCanvas.getContext("2d", {
// Boolean that indicates if the canvas contains an alpha channel.
// If set to false, the browser now knows that the backdrop is always opaque,
// which can speed up drawing of transparent content and images.
// (lights don't have an alpha channel, so let's drop it)
alpha: false,
desynchronized: true,
imageSmoothingEnabled: false
});

export default {
name: "Grab Canvas",
props: {
mappingWidth: {
type: "int",
default: 7,
min: 1,
max: 1024,
step: 1,
abs: true
},

mappingHeight: {
type: "int",
default: 7,
min: 1,
max: 1024,
step: 1,
abs: true
},

url: {
type: "text",
default: "ws://localhost:3006/modV"
},

reconnectAfter: {
type: "int",
default: 4000,
min: 1000,
max: 60000,
step: 1,
abs: true
},

shouldReconnect: {
type: "bool",
default: true
}
},

async init({ store, props }) {
if (!outputContext) {
outputContext = await store.dispatch("outputs/getAuxillaryOutput", {
name: "Fixture Canvas",
group: "Plugins",
canvas: mappingCanvas,
context: mappingContext,
reactToResize: false
});
}

mappingCanvas.width = props.mappingWidth;
mappingCanvas.height = props.mappingHeight;

reconnect = props.shouldReconnect;

this.setupSocket(props);
},

shutdown() {
this.stopReconnect();
this.closeConnection();
},

/**
* Create a WebSocket for example to luminave
*/
setupSocket(props) {
const { url, reconnectAfter } = props;

// Close an old connection
this.closeConnection();

// Create a new connection
connection = new WebSocket(url);

// Listen for errors (e.g. could not connect)
connection.addEventListener("error", event => {
console.error("grab-canvas: WebSocket: Error:", event);

// Reconnect is allowed
if (reconnect) {
// Reconnect after a specific amount of time
timeout = setTimeout(() => {
this.setupSocket(props);
}, reconnectAfter);
}
});

// Connection is opened
connection.addEventListener("open", () => {
console.info("grab-canvas: WebSocket: Opened");
});

connection.addEventListener("close", () => {
console.info("grab-canvas: WebSocket: Closed");
});
},

/**
* Close the WebSocket connection and stop reconnecting
*/
closeConnection() {
clearTimeout(timeout);

if (connection !== undefined) {
connection.close();
}

connection = undefined;
},

/**
* Stop reconnecting to WebSocket
*/
stopReconnect() {
reconnect = false;
clearTimeout(timeout);
},

postProcessFrame({ canvas, props }) {
mappingContext.clearRect(0, 0, canvas.width, canvas.height);
mappingContext.drawImage(
canvas,
0,
0,
canvas.width,
canvas.height,
0,
0,
props.mappingWidth,
props.mappingHeight
);

const imageData = mappingContext.getImageData(
0,
0,
props.mappingWidth,
props.mappingHeight
);
const { data } = imageData;
const arrayData = Array.from(data);
const rgbArray = arrayData.filter((value, index) => (index + 1) % 4 !== 0);

this.send(rgbArray);
},

/**
* Send data to WebSocket if connection is established
* @param {Object} data
*/
send(data) {
// Connection is established
if (connection !== undefined && connection.readyState === 1) {
const message = {
_type: "modV",
colors: data
};

const messageString = JSON.stringify(message, null, 2);

// Send JSON message
connection.send(messageString);
}
}
};
5 changes: 4 additions & 1 deletion src/application/worker/index.worker.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
/* eslint-env worker node */

const { default: constants } = require("../constants");

let lastKick = false;
@@ -9,6 +8,8 @@ async function start() {
const fs = require("fs");
const store = require("./store").default;
const loop = require("./loop").default;
const grabCanvasPlugin = require("../plugins/grab-canvas").default;

const { tick: frameTick } = require("./frame-counter");
const { getFeatures, setFeatures } = require("./audio-features");
// const featureAssignmentPlugin = require("../plugins/feature-assignment");
@@ -45,6 +46,8 @@ async function start() {
});
});

store.dispatch("plugins/add", grabCanvasPlugin);

const renderers = require.context("../renderers/", false, /\.js$/);

const rendererKeys = renderers.keys();
19 changes: 17 additions & 2 deletions src/application/worker/loop.js
Original file line number Diff line number Diff line change
@@ -96,7 +96,11 @@ function loop(delta, features, fftOutput) {
const preProcessFrameFunctionsLength = preProcessFrameFunctions.length;

for (let i = 0; i < preProcessFrameFunctionsLength; ++i) {
preProcessFrameFunctions[i].preProcessFrame({ features, store });
preProcessFrameFunctions[i].preProcessFrame({
features,
store,
props: preProcessFrameFunctions[i].$props
});
}

const renderersWithTick = store.getters["renderers/renderersWithTick"];
@@ -338,7 +342,18 @@ function loop(delta, features, fftOutput) {
}
}

// main.getImageData(0, 0, main.canvas.width, main.canvas.height);
const postProcessFrameFunctions =
store.getters["plugins/postProcessFrame"] || [];
const postProcessFrameFunctionsLength = postProcessFrameFunctions.length;

for (let i = 0; i < postProcessFrameFunctionsLength; ++i) {
postProcessFrameFunctions[i].postProcessFrame({
canvas: main.canvas,
features,
store,
props: postProcessFrameFunctions[i].$props
});
}
}

export default loop;
12 changes: 8 additions & 4 deletions src/application/worker/store/modules/outputs.js
Original file line number Diff line number Diff line change
@@ -49,6 +49,7 @@ const actions = {
{ dispatch },
{
canvas = new OffscreenCanvas(300, 300),
context,
name,
type = "2d",
options = {},
@@ -63,13 +64,16 @@ const actions = {
options.storage = "discardable";
}

const context = canvas.getContext(type, options);
canvas.width = width;
canvas.height = height;
if (!context) {
canvas.width = width;
canvas.height = height;
}

const canvasContext = context || canvas.getContext(type, options);

const outputContext = await dispatch("addAuxillaryOutput", {
name,
context,
context: canvasContext,
reactToResize,
group,
id
108 changes: 106 additions & 2 deletions src/application/worker/store/modules/plugins.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import store from "../";
import getPropDefault from "../../../utils/get-prop-default";

import uuidv4 from "uuid/v4";

@@ -15,12 +16,16 @@ const state = [];

const getters = {
preProcessFrame: state => {
return state.filter(plugin => !!plugin.preProcessFrame);
return state.filter(plugin => !!plugin.preProcessFrame && plugin.enabled);
},

postProcessFrame: state => {
return state.filter(plugin => !!plugin.postProcessFrame && plugin.enabled);
}
};

const actions = {
add({ commit }, plugin) {
async add({ commit }, plugin) {
if (!("name" in plugin)) {
throw new Error("Plugin requires a name");
}
@@ -30,14 +35,113 @@ const actions = {
store.registerModule(storeName, plugin.store);
}

plugin.$props = {};

if ("props" in plugin) {
const keys = Object.keys(plugin.props);
const keysLength = keys.length;

for (let i = 0; i < keysLength; i += 1) {
const key = keys[i];
const prop = plugin.props[key];

plugin.$props[key] = await getPropDefault(module, key, prop, false);

if (!plugin.$props[key]) {
plugin.$props[key] = null;
}
}
}

plugin.id = uuidv4();
plugin.enabled = false;
commit("ADD_PLUGIN", plugin);
},

setEnabled({ commit }, { pluginId, enabled }) {
const plugin = state.find(item => item.id === pluginId);

if (!plugin) {
return false;
}

if (enabled) {
if ("init" in plugin) {
plugin.init({ store, props: plugin.$props });
}
} else {
if ("shutdown" in plugin) {
plugin.shutdown({ store, props: plugin.$props });
}
}

commit("SET_PLUGIN_ENABLE", { pluginId, enabled });
},

async updateProp({ commit }, { pluginId, prop, data }) {
const plugin = state.find(item => item.id === pluginId);

if (!plugin) {
return false;
}

let dataOut = data;

const propData = plugin.props[prop];
const currentValue = plugin.$props[prop];
const { type } = propData;

if (data === currentValue) {
return;
}

if (store.state.dataTypes[type] && store.state.dataTypes[type].create) {
dataOut = await store.state.dataTypes[type].create(dataOut);
}

if (!Array.isArray(dataOut)) {
const { strict, min, max, abs } = propData;

if (strict && typeof min !== "undefined" && typeof max !== "undefined") {
dataOut = Math.min(Math.max(dataOut, min), max);
}

if (abs) {
dataOut = Math.abs(dataOut);
}

if (type === "int") {
dataOut = Math.round(dataOut);
}
}

commit("UPDATE_PROP", { pluginId, prop, data: dataOut });
}
};

const mutations = {
ADD_PLUGIN(state, plugin) {
state.push(plugin);
},

SET_PLUGIN_ENABLE(state, { pluginId, enabled }) {
const plugin = state.find(item => item.id === pluginId);

if (!plugin) {
return false;
}

plugin.enabled = enabled;
},

UPDATE_PROP(state, { pluginId, prop, data }) {
const plugin = state.find(item => item.id === pluginId);

if (!plugin) {
return false;
}

plugin.$props[prop] = data;
}
};

85 changes: 40 additions & 45 deletions src/components/Control.vue
Original file line number Diff line number Diff line change
@@ -9,42 +9,43 @@
</c>
<c span="3">
<div class="input" v-if="component">
<component :is="component" v-model="value" />
<component :is="component" v-model="internalValue" />
</div>
<div class="input" v-else-if="type === 'int' || type === 'float'">
<RangeControl
:min="min"
:max="max"
v-model.number="value"
:step="step"
v-model.number="internalValue"
:type="type"
/>
</div>
<div class="input" v-else-if="type === 'tween'">
<TweenControl v-model="value" />
<TweenControl v-model="internalValue" />
</div>
<div class="input" v-else-if="type === 'vec2'">
<Vec2DControl v-model="value" />
<Vec2DControl v-model="internalValue" />
</div>
<div class="input" v-else-if="type === 'vec3'">
<Vec3Control v-model="value" />
<Vec3Control v-model="internalValue" />
</div>
<div class="input" v-else-if="type === 'vec4'">
<Vec4Control v-model="value" />
<Vec4Control v-model="internalValue" />
</div>
<div class="input" v-else-if="type === 'text'">
<input type="text" v-model="value" />
<input type="text" v-model="internalValue" />
</div>
<div class="input" v-else-if="type === 'bool'">
<Checkbox :class="{ light: !inputIsFocused }" v-model="value" />
<Checkbox :class="{ light: !inputIsFocused }" v-model="internalValue" />
</div>
<div class="input" v-else-if="type === 'color'">
<ColorControl v-model="value" :moduleId="id" :prop="prop" />
<ColorControl v-model="internalValue" :moduleId="id" :prop="prop" />
</div>
<div class="input" v-else-if="type === 'texture'">
<TextureControl v-model="value" />
<TextureControl v-model="internalValue" />
</div>
<div class="input" v-else-if="type === 'enum'">
<select v-model="value">
<select v-model="internalValue">
<option
v-for="(option, index) in activeProp.enum"
:value="option.value"
@@ -75,7 +76,26 @@ import inputIsFocused from "./mixins/input-is-focused";
export default {
mixins: [hasLink, inputIsFocused],
props: ["id", "prop"],
props: {
inputTitle: {
type: String,
default: ""
},
title: {
type: String,
required: true
},
value: {
required: true
},
activeProp: {
type: Object,
required: true
}
},
components: {
RangeControl,
@@ -105,17 +125,9 @@ export default {
methods: {
async queueLoop() {
const { id, prop, queued } = this;
try {
await this.$modV.store.dispatch("modules/updateProp", {
moduleId: id,
prop,
data: queued
});
} catch (e) {
console.error(e.message);
}
const { queued } = this;
this.$emit("input", queued);
this.queued = null;
this.dirty = false;
@@ -124,22 +136,12 @@ export default {
focusInput() {
this.$modV.store.dispatch("inputs/setFocusedInput", {
id: this.inputId,
title: `${this.moduleName}: ${this.title}`
title: this.inputTitle
});
}
},
computed: {
activeProp() {
const { id, prop } = this;
return this.$modV.store.state.modules.active[id].$props[prop];
},
moduleName() {
const { id } = this;
return this.$modV.store.state.modules.active[id].meta.name;
},
inputId() {
return this.activeProp.id;
},
@@ -156,24 +158,17 @@ export default {
return this.activeProp.max;
},
title() {
return this.activeProp.label || this.prop;
step() {
return this.activeProp.step;
},
component() {
return this.activeProp.component || false;
},
value: {
internalValue: {
get() {
const { id, prop, type } = this;
const propData = this.$modV.store.state.modules.active[id].props[prop];
if (type === "tween") {
return this.$modV.store.state.tweens.tweens[propData.id];
}
return propData;
return this.value;
},
set(value) {
5 changes: 3 additions & 2 deletions src/components/Controls/RangeControl.vue
Original file line number Diff line number Diff line change
@@ -11,7 +11,7 @@
<Number
v-model="inputValue"
type="number"
step="0.01"
:step="step"
@keypress.enter="toggleEditMode"
@click.right="toggleEditMode"
v-show="editMode"
@@ -37,7 +37,7 @@ export default {
},
step: {
type: Number,
default: 2
default: 1
},
default: Number,
value: Number
@@ -77,6 +77,7 @@ export default {
created() {
this.position = -this.value * this.spacingCalc;
this.inputValue = this.value;
},
mounted() {
79 changes: 79 additions & 0 deletions src/components/ModuleControl.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<template>
<Control
@input="handleInput"
:inputTitle="`${moduleName}: ${title}`"
:activeProp="activeProp"
:title="title"
:value="value"
/>
</template>

<script>
import Control from "./Control";
export default {
components: {
Control
},
props: {
prop: {
type: String,
required: true
},
id: {
type: String,
required: true
}
},
computed: {
activeProp() {
const { id, prop } = this;
return this.$modV.store.state.modules.active[id].$props[prop];
},
type() {
return this.activeProp.type;
},
moduleName() {
const { id } = this;
return this.$modV.store.state.modules.active[id].meta.name;
},
title() {
return this.activeProp.label || this.prop;
},
value() {
const { id, prop, type } = this;
const propData = this.$modV.store.state.modules.active[id].props[prop];
if (type === "tween") {
return this.$modV.store.state.tweens.tweens[propData.id];
}
return propData;
}
},
methods: {
async handleInput(e) {
const { prop, id: moduleId } = this;
const data = e;
try {
await this.$modV.store.dispatch("modules/updateProp", {
moduleId,
prop,
data
});
} catch (e) {
console.error(e.message);
}
}
}
};
</script>
6 changes: 3 additions & 3 deletions src/components/ModuleInspector.vue
Original file line number Diff line number Diff line change
@@ -9,7 +9,7 @@
<div class="title">{{ module.meta.name }}</div>
</c>
<c span="1..">
<Control
<ModuleControl
v-for="key in getProps(module.$moduleName)"
:id="module.$id"
:prop="key"
@@ -20,13 +20,13 @@
</template>

<script>
import Control from "@/components/Control";
import ModuleControl from "@/components/ModuleControl";
export default {
props: ["moduleId"],
components: {
Control
ModuleControl
},
mounted() {
85 changes: 85 additions & 0 deletions src/components/PluginControl.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<template>
<Control
@input="handleInput"
:inputTitle="`${pluginName}: ${title}`"
:activeProp="activeProp"
:title="title"
:value="value"
/>
</template>

<script>
import Control from "./Control";
export default {
components: {
Control
},
props: {
prop: {
type: String,
required: true
},
id: {
type: String,
required: true
}
},
computed: {
plugin() {
const { id } = this;
return this.$modV.store.state.plugins.find(item => item.id === id);
},
activeProp() {
const { plugin, prop } = this;
return plugin.props[prop];
},
type() {
return this.activeProp.type;
},
pluginName() {
return this.plugin.name;
},
title() {
return this.activeProp.label || this.prop;
},
value() {
const { prop, type, plugin } = this;
const propData = plugin.$props[prop];
if (type === "tween") {
return this.$modV.store.state.tweens.tweens[propData.id];
}
return propData;
}
},
methods: {
async handleInput(e) {
const { prop, id: pluginId } = this;
const data = e;
try {
await this.$modV.store.dispatch("plugins/updateProp", {
pluginId,
prop,
data
});
} catch (e) {
console.error(e.message);
}
}
}
};
</script>
89 changes: 89 additions & 0 deletions src/components/Plugins.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<template>
<grid
class="borders"
v-infoView="{ title: iVTitle, body: iVBody, id: 'Plugins Panel' }"
v-searchTerms="{
terms: ['plugin', 'plug in', 'addon', 'add-on'],
title: 'Plugins',
type: 'Panel'
}"
>
<CollapsibleRow v-for="plugin in plugins" :key="plugin.id">
<template v-slot:label>
{{ plugin.name }}
</template>

<template v-slot:body>
<c span="1..">
<grid columns="4">
<c span="1..">
<grid columns="4">
<c span="1">
Enable
</c>
<c span="3">
<Checkbox
@input="handleEnableInput(plugin.id)"
class="light"
/>
</c>
</grid>
</c>

<c
span="1.."
v-for="(prop, propKey) in plugin.$props"
:key="propKey"
>
<PluginControl :id="plugin.id" :prop="propKey" />
</c>
</grid>
</c>
</template>
</CollapsibleRow>
</grid>
</template>

<script>
import CollapsibleRow from "./CollapsibleRow.vue";
import Checkbox from "./inputs/Checkbox.vue";
import PluginControl from "./PluginControl.vue";
export default {
components: {
CollapsibleRow,
Checkbox,
PluginControl
},
data() {
return {
iVTitle: "Plugins",
iVBody: "The Plugins panel lists all available Plugins."
};
},
computed: {
plugins() {
return this.$modV.store.state.plugins;
}
},
methods: {
handleEnableInput(pluginId) {
const plugin = this.plugins.find(item => item.id === pluginId);
if (!plugin) {
return;
}
this.$modV.store.dispatch("plugins/setEnabled", {
pluginId,
enabled: !plugin.enabled
});
}
}
};
</script>

<style></style>

0 comments on commit d7ca887

Please sign in to comment.