From 4ecdf4f5bcdb8f57d6c78f119cd586e46c4021ed Mon Sep 17 00:00:00 2001 From: Jake Read <jake.read@cba.mit.edu> Date: Mon, 7 Oct 2019 12:20:53 -0400 Subject: [PATCH] chat with leo, hello workers --- cf.js | 52 ++++--- gogetter.js | 2 +- hunks/hunks.js | 4 + hunks/image/readpng_leo.js | 107 +++++++++++++++ hunks/image/thresholdrgba_leo.js | 109 +++++++++++++++ hunks/pipes/pipetemplate.js | 34 +++++ hunks/pipes/vfpt.js | 164 +++++++++++++++++++++++ hunks/{comm => pipes}/websocketclient.js | 0 pipes/pipetemplate.js | 5 + scratch/test.png | Bin 0 -> 4028 bytes 10 files changed, 457 insertions(+), 20 deletions(-) create mode 100644 hunks/image/readpng_leo.js create mode 100644 hunks/image/thresholdrgba_leo.js create mode 100644 hunks/pipes/pipetemplate.js create mode 100644 hunks/pipes/vfpt.js rename hunks/{comm => pipes}/websocketclient.js (100%) create mode 100644 pipes/pipetemplate.js create mode 100644 scratch/test.png diff --git a/cf.js b/cf.js index 8589b95..860be38 100644 --- a/cf.js +++ b/cf.js @@ -7,14 +7,20 @@ const bodyparser = require('body-parser') // our fs tools, const fs = require('fs') const filesys = require('./filesys.js') +// and we occasionally spawn local pipes (workers) +const { + Worker, workerData +} = require('worker_threads') // will use these to figure where tf we are const os = require('os') let ifaces = os.networkInterfaces() // serve everything: https://expressjs.com/en/resources/middleware/serve-static.html app.use(express.static(__dirname)) +// accept post bodies as json, app.use(bodyparser.json()) app.use(bodyparser.urlencoded({extended: true})) + // if these don't exist, they get 'nexted' to any other 'middleware' we write app.get('/fileList', (req, res) => { try{ @@ -42,6 +48,7 @@ app.get('/fileList', (req, res) => { res.send('server-side error retrieving list') } }) + // we also handle file-saving this way, app.post('/save/contexts/:context/:file', (req, res) => { // this is probably fine for now, but I kind of want a websocket to do this kind of stuff ? @@ -68,11 +75,35 @@ app.post('/save/systems/:file', (req, res) => { }) }) +// we also want to institute some pipes: this is a holdover for a better system +// more akin to nautilus, where server-side graphs are manipulated +// for now, we just want to dive down to a usb port, probably, so this shallow link is OK +app.get('/pipeHookup/:file', (req, res) => { + // we can assume that the file is a reciprocal pipe-type in our local pipes/file.js location + // we'll open that can as a spawn, can assume it's hosting a websocket (it will tell us the port?) + // and we can send that information back up stream, + console.log('/pipes', req.params.file) + const piper = new Worker(`${__dirname}/pipes/${req.params.file}`) + piper.on('message', (msg) => { + console.log('worker msg', msg) + }) + piper.on('error', (err) => { + console.log('worker err', err) + }) + piper.on('exit', (code) => { + console.log('exit code', code) + }) + res.send({address: 'localip', port: '1024'}) + // then this (or similar) should be all we really need to add here, and we can do local-dev of the pipes in hunks/pipes/pipename.js and pipes/pipename.js + // so, do we spawn, or do we use workers ? + // awh yis it's workers, messages easy, errors also OK to catch... nextup: draw the sys, what wraps what doesn't? mostly: want to be able to refresh / reload / restart remotely +}) + +// finally, we tell the thing to listen here: let port = 8080 -// and listen, app.listen(port) -// want to announce our existence, this just logs our IPs to the console: +// this just logs the processes IP's to the termina Object.keys(ifaces).forEach(function(ifname) { var alias = 0; @@ -93,20 +124,3 @@ Object.keys(ifaces).forEach(function(ifname) { ++alias; }); }); - - -/* -let begin = () => { - // setup to dish files, - ex.get('/', (req, res) => { - console.log('req /') - res.sendFile(__dirname + '/index.html') - }) - - http.listen(8080, () => { - console.log("is listening on 8080") - }) -} - -begin() -*/ diff --git a/gogetter.js b/gogetter.js index 767eb1c..2be6b76 100644 --- a/gogetter.js +++ b/gogetter.js @@ -8,7 +8,7 @@ function GoGetter() { this.recursivePathSearch = (root, debug) => { return new Promise((resolve, reject) => { jQuery.get(`/fileList?path=${root}`, (resp) => { - console.log('resp at jq', resp) + //console.log('resp at jq', resp) resolve(resp) }) }) diff --git a/hunks/hunks.js b/hunks/hunks.js index 3ad3bfa..eae5b6f 100644 --- a/hunks/hunks.js +++ b/hunks/hunks.js @@ -325,6 +325,10 @@ function deepCopy(obj) { // turns out parse/stringify is the fastest, //console.log('to dc', obj) //console.log(JSON.stringify(obj)) + if(obj instanceof ImageData) { + let newData = new ImageData(obj.data, obj.width, obj.height) + return newData + } return JSON.parse(JSON.stringify(obj)) } diff --git a/hunks/image/readpng_leo.js b/hunks/image/readpng_leo.js new file mode 100644 index 0000000..2426ce6 --- /dev/null +++ b/hunks/image/readpng_leo.js @@ -0,0 +1,107 @@ +/* + +hunk template + +*/ + +// these are ES6 modules +import { + Hunkify, + Input, + Output, + State +} from '../hunks.js' + +import { + html, + svg, + render +} from 'https://unpkg.com/lit-html?module'; + +export default function UploadPNG() { + // this fn attaches handles to our function-object, + Hunkify(this) + + const imageOut = new Output('RGBA', 'Image', this) + this.outputs.push(imageOut) + + this.init = () => { + this.dom = $('<div>').get(0) + } + + //import + const png_read_handler = (e) => { + const reader = new FileReader(); + + const files = e.target.files; + const file = files[0]; + + const img = new Image(); + img.file = file; + + const loader = (aImg) => (e) => { + aImg.src = e.target.result; + + aImg.onload = () => { + const canvas = document.getElementById("wackyAndTotallyUniqueID2"); + const ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(aImg, 0, 0); + + // string other functions here + const data = getImageDataRGBA(); + // console.log("data", data); + + imageOut.put(data) + // console.log(data); + // console.log("copied", JSON.stringify(data)); + } + + }; + reader.onload = loader(img); + reader.readAsDataURL(file); + } + + // get image data: RGBA + const getImageDataRGBA = () => { //should add dpi term here + const canvas = document.getElementById("wackyAndTotallyUniqueID2"); + const ctx = canvas.getContext("2d"); + const imageRGBA = ctx.getImageData(0, 0, canvas.width, canvas.height); + return imageRGBA; + } + + this.onload = () => { + const target = $('<div>').get(0); + $(this.dom).append(target); + + const view = html ` + <style> + #wackyAndTotallyUniqueID2 { + border: 1px solid black; + margin: 5px; + } + + #wackyAndTotallyUniqueID { + position: absolute; + left: 10px; + top: 350px; + } + </style> + <div>Upload!</div> + <canvas + id="wackyAndTotallyUniqueID2" + width=300 + height=300> + </canvas> + <input + id="wackyAndTotallyUniqueID" + type="file" + accept="image/png" + @input=${(e) => png_read_handler(e)}></input> + ` + render(view, target); + } + + + this.loop = () => {} +} diff --git a/hunks/image/thresholdrgba_leo.js b/hunks/image/thresholdrgba_leo.js new file mode 100644 index 0000000..fcd4d6f --- /dev/null +++ b/hunks/image/thresholdrgba_leo.js @@ -0,0 +1,109 @@ +/* + +hunk template + +*/ + +// these are ES6 modules +import { + Hunkify, + Input, + Output, + State +} from '../hunks.js' + +import { + html, + svg, + render +} from 'https://unpkg.com/lit-html?module'; + + +export default function Threshold() { + // this fn attaches handles to our function-object, + Hunkify(this) + + // inputs + let imageIn = new Input('RGBA', 'Image', this); + this.inputs.push(imageIn); + + // states + let threshold = new State('number', 'Threshold', .5); + this.states.push(threshold); + + // outputs + let imageOut = new Output('RGBA', 'Image', this); + this.outputs.push(imageOut); + + // Helper Functions + const thresholdRGBA = (imageRGBA, threshold) => { + console.log(imageRGBA) + const w = imageRGBA.width; + const h = imageRGBA.height; + const buf = imageRGBA.data; + const t = threshold; + + let r, g, b, a, i; + for (var row = 0; row < h; ++row) { + for (var col = 0; col < w; ++col) { + r = buf[(h - 1 - row) * w * 4 + col * 4 + 0]; + g = buf[(h - 1 - row) * w * 4 + col * 4 + 1]; + b = buf[(h - 1 - row) * w * 4 + col * 4 + 2]; + a = buf[(h - 1 - row) * w * 4 + col * 4 + 3]; + i = (r + g + b) / (3 * 255); + + let val; + if (a === 0) { + val = 255; + } else if (i > t) { + val = 255; + } else { + val = 0; + } + + buf[(h - 1 - row) * w * 4 + col * 4 + 0] = val; + buf[(h - 1 - row) * w * 4 + col * 4 + 1] = val; + buf[(h - 1 - row) * w * 4 + col * 4 + 2] = val; + buf[(h - 1 - row) * w * 4 + col * 4 + 3] = 255; + } + } + + const imgdata = new ImageData(buf, w, h) + + return imgdata; + } + + // view + // as is tradition + this.dom = {} + this.init = () => { + this.dom = $('<div>').get(0) + } + + this.onload = () => { + const target = $('<div>').get(0); + $(this.dom).append(target); + + const view = html ` + <button + @mousedown=${() => { + // console.log("imageIn", imageIn.get()); + // console.log("copied", deepCopy(imageIn)); + const newOut = thresholdRGBA(imageIn.get(), threshold.value) + imageOut.put(newOut); + }}> + Calculate + </button> + ` + + render(view, target); + } + + //loop + this.loop = () => { + if(imageIn.io() && !imageOut.io()){ + const thresholded = thresholdRGBA(imageIn.get(), threshold.value) + imageOut.put(thresholded) + } + } +} diff --git a/hunks/pipes/pipetemplate.js b/hunks/pipes/pipetemplate.js new file mode 100644 index 0000000..9a7c5f3 --- /dev/null +++ b/hunks/pipes/pipetemplate.js @@ -0,0 +1,34 @@ +/* + +pipes are websocket-having devices that commune with our server, +this is a scratch / example of one such object + +*/ + +import { + Hunkify, + Input, + Output, + State +} from '../hunks.js' + +export default function Pipe() { + Hunkify(this) + let debug = true + + let statusMessage = new State('string', 'status', 'closed') + let retryButton = new State('boolean', 'retry', false) + this.states.push(statusMessage, retryButton) + + // coming merge of init and onload, however: + this.init = () => { + // hijack ajax to ask for a websocket + jQuery.get('pipeHookup/pipetemplate.js', (data) => { + console.log('pipe jquery data', data) + }) + } + + this.loop = () => { + // ws status, ws messages ... + } +} diff --git a/hunks/pipes/vfpt.js b/hunks/pipes/vfpt.js new file mode 100644 index 0000000..5627185 --- /dev/null +++ b/hunks/pipes/vfpt.js @@ -0,0 +1,164 @@ +/* + +very fast ~~picket ship~~ pipe transport + +*/ + +import { + Hunkify, + Input, + Output, + State +} from '../hunks.js' + +function VFPT() { + Hunkify(this) + + let debug = false + + let dtin = new Input('byteArray', 'data', this) + this.inputs.push(dtin) + + let dtout = new Output('byteArray', 'data', this) + this.outputs.push(dtout) + + // TODO is tackling state sets / updates / onupdate fn's + // this is hunk -> manager commune ... + let statusMessage = new State('string', 'status', 'closed') + let retryCountHandle = new State('number', 'retrycount', 3) + let resetRetryHandle = new State('boolean', 'retryreset', false) + let addressState = new State('string', 'address', '127.0.0.1') + let portState = new State('number', 'port', 2042) + this.states.push(statusMessage, retryCountHandle, resetRetryHandle, addressState, portState) + + // this ws is a client, + let ws = {} + let url = 'ws://127.0.0.1:2020' + this.outbuffer = new Array() + + this.init = () => { + setTimeout(startWs, 500) + } + + resetRetryHandle.change = (value) => { + retryCountHandle.set(3) + startWs() + // to actually change the value, we would do: + // resetRetryHandle.set(value) + } + + let startWs = () => { + // manager calls this once + // it is loaded and state is updated (from program) + url = 'ws://' + addressState.value + ':' + portState.value + this.log(`attempt start ws at ${url}`) + ws = new WebSocket(url) + ws.binaryType = "arraybuffer" + ws.onopen = (evt) => { + this.log('ws opened') + statusMessage.set('open') + } + ws.onerror = (evt) => { + this.log('ws error, will reset to check') + console.log('ws error:', evt) + if(debug) console.log(evt) + statusMessage.set('error') + setCheck(500) + } + ws.onclose = (evt) => { + this.log('ws close') + setCheck(500) + } + ws.onmessage = (message) => { + // this should be a buffer + if(debug) console.log('WS receives', message.data) + // tricks? + // ok, message.data is a blob, we know it's str8 up bytes, want that + // as an array + let msgAsArray = new Uint8Array(message.data) + // it's messy, yep! + let msgAsStdArray = Array.from(msgAsArray) + if(debug) console.log('WS receive, as an array:', msgAsArray); + if (dtout.ie && this.outbuffer.length === 0) { + dtout.put(msgAsStdArray) + } else { + this.outbuffer.push(msgAsStdArray) + } + } + statusMessage.set('ws initialized...') + } + + let checking = false + + let setCheck = (ms) => { + if (checking) { + // noop + } else { + setTimeout(checkWsStatus, ms) + checking = true + } + } + + let checkWsStatus = () => { + let retrycount = retryCountHandle.value - 1 + if (retrycount < 1) { + // give up + statusMessage.set('not connected') + retryCountHandle.set(0) + checking = false + } else { + retryCountHandle.set(retrycount) + checking = false + this.log('CHECKING STATUS') + switch (ws.readyState) { + case WebSocket.CONNECTING: + this.log('ws is in process of connecting...') + break + case WebSocket.OPEN: + this.log('is open') + break + case WebSocket.CLOSING: + this.log('is closing') + break + case WebSocket.CLOSED: + this.log('is closed, retrying ...') + startWs() + break + default: + throw new Error('nonsensical result at ws readystate check for ws') + break + } + } + } + + // override default change f'n + retryCountHandle.change = (value) => { + this.log('retrycount reset') + retryCountHandle.set(value) + setCheck(10) + } + + this.loop = () => { + // something like if(ws !== null && ws.isopen) + // if we have an open port, and have bytes to send downstream, + if (ws !== null && ws.readyState === 1) { + // no buffering + if (dtin.io()) { + let arr = dtin.get() + if(debug) console.log('WS transmission as array', arr) + let bytesOut = Uint8Array.from(arr) + // HERE insertion -> buffer.from() ? + if(debug) console.log("WS sending buffer", bytesOut.buffer) + ws.send(bytesOut.buffer) + } + } + + // check if we have outgoing to pass along + if (this.outbuffer.length > 0 && !dtout.io()) { + dtout.put(this.outbuffer.shift()) + } + + } +} + +export default VFPT diff --git a/hunks/comm/websocketclient.js b/hunks/pipes/websocketclient.js similarity index 100% rename from hunks/comm/websocketclient.js rename to hunks/pipes/websocketclient.js diff --git a/pipes/pipetemplate.js b/pipes/pipetemplate.js new file mode 100644 index 0000000..913ae02 --- /dev/null +++ b/pipes/pipetemplate.js @@ -0,0 +1,5 @@ +const { + parentPort +} = require('worker_threads') + +parentPort.postMessage('hello worker') diff --git a/scratch/test.png b/scratch/test.png new file mode 100644 index 0000000000000000000000000000000000000000..8d416e2339529d7e07a8fd2aeb97b6746bf65eb9 GIT binary patch literal 4028 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 literal 0 HcmV?d00001 -- GitLab