meltingice / CamanJS

Javascript HTML5 (Ca)nvas (Man)ipulation
http://camanjs.com
BSD 3-Clause "New" or "Revised" License
3.55k stars 404 forks source link

Support WebWorker Environment #183

Open jocooler opened 8 years ago

jocooler commented 8 years ago

It would be great if Caman could run in a webworker. I know that's where Caman came from, but it seems challenging enough to implement on the current codebase that is warrants creating an issue. Some of this was discussed in #173, but this is an official issue/request for the feature.

Couple of thoughts off the top of my head:

  1. Caman would have to accept and operate directly on UInt8ClampedArrays in addition to images, canvases, etc.
  2. Caman would have to postMessage back a UInt8ClampedArray instead of rendering to canvas.

Adding typeof document !== "undefined" in a few places made the code not have errors in a Worker, but without UInt8ClampedArray support, it won't process any images.

Since there's no access to the DOM/creating elements, all the Caman.prototype.initXXXXX functions run into errors.

jocooler commented 8 years ago

Things that will work in a worker:

  1. Math operations on an array of pixel data

Things that won't work in the worker:

  1. Any of the canvas native functions (toDataURL, drawImage, etc)
  2. Loading additional images (for layering, etc)
  3. Any image decoding/encoding, or we'd have to include our own PNG/JPG/WebP codecs

I made some progress on this (initializes in the worker from a UInt8ClampedArray) but realized I've been working from the 4.1.1 zip I got from the website, not the latest github pull.

So it's back to the ol' drawing board for a while.

jocooler commented 8 years ago

Ok, so I have a branch that is mostly working. Layering and resizing/cropping don't work at present but I'm working on that.

https://github.com/jocooler/CamanJS/tree/webworker-environment

The big thing that worker implementations must do is define an exposeImageData(data, width, height) function that will handle the processed data.

EmanH commented 7 years ago

@jocooler Any progress on this?

jocooler commented 7 years ago

@EmanH - I'm working on other projects for the time being. Figuring out how to decode/encode the image in the webworker is hard, potentially more computationally expensive than the savings from forking the webworker. And beyond my skill at this point. And a lot of this would be better handled by the GPU. So no further progress and I'm not actively working on it now.

EmanH commented 7 years ago

@jocooler I ended up getting it working. Crop and Vignette is done in the browser, the rest in the webworker.

ctf0 commented 6 years ago

@EmanH is there a chance to add a demo or some explanation on how u managed to run inside the worker ?

av01d commented 5 years ago

@EmanH: Would you be so kind to share your solution? Thanks!

EmanH commented 5 years ago

@ctf0 and @av01d

Something like this:

editor = Caman(selector);

$scope.settings = {
    brightness: 0,
    contrast: 0,
    saturation: 0,
    vibrance: 0,
    exposure: 0,
    hue: 0,
    sepia: 0,
    gamma: 0,
    blur: 0,
    size: 0,
    strength: 40,
    blacks: 50,
    shadows: 80,
    midtones: 180,
    highlights: 255,
    red: 0,
    green: 0,
    blue: 0
};

var original = angular.copy($scope.settings);

if (window.Worker) {
    var workerProcess = new Worker(ROOT + 'js/admin/imageProcessor.js');

    var keys = ['image', 'tabs', 'settings', 'cropped', 'rotate'],
        output = {};
    _.each(keys, function(key) {
        output[key] = $scope[key];
    });

    output.width = ($scope.cropped.is && $scope.cropped.curCrop.w) || dimensions.width
    output.height = ($scope.cropped.is && $scope.cropped.curCrop.h) || dimensions.height;

    workerProcess.postMessage({ scope: angular.toJson(output), image: editor.imageData, original: original });

    workerProcess.onmessage = function(e) {

        $scope.$apply(function() {

            sv.message = e.data.message;
            if (e.data.percent) {
                sv.percent = percent + ((e.data.percent / 100) * (65 - percent));
            }

            if (e.data.type == 'finished') {

                sv.percent = 65;

                var newImgData = new ImageData(e.data.image, editor.imageData.width, editor.imageData.height);

                editor.imageData.data = e.data.image;
                editor.pixelData = e.data.image;

                editor.render(function() {

                    sv.percent = 70;

                    db.q('saveImageEdit', {
                        base64: this.toBase64(),
                        original: $scope.image.kp_imageID,
                        overwrite: overwrite
                    }, undefined, undefined, function(e) {
                        if (e.lengthComputable) {
                            sv.percent = 70 + ((e.loaded / e.total) * 30);
                        }
                    }).then(function(r) {
                        sv.percent = 100;
                        sv.message = "Upload Complete";
                        workerProcess.terminate();
                        if (r.image) {
                            ngModel.$setViewValue(r.image.kp_imageID);
                            $scope.ngModel = r.image.kp_imageID;
                            ngModel.$render();
                        }
                        def.resolve(r);
                    });

                }, newImgData);
            }

        });
    }

}
EmanH commented 5 years ago

imageProcessor.js

var window = {Uint8Array: 1};
importScripts('caman.zaak.full.min.js');
importScripts('underscore-min.js');

this.exposeImageData = function (imgData){
    onmessage = undefined;
    postMessage({ type:"finished", message: "Processing Complete. Now saving...", image: imgData });
}

onmessage = function(e) {

    var data = e.data,
        $scope = JSON.parse(data.scope),
        original = data.original,
        editor;

    function apply() {

        var numFilters = 0;

        var st = $scope.settings;
        _.each($scope.tabs, function(itm) {
            if (itm.label == 'Vignette') {

                if (st.size > 0) {
                    editor.vignette(st.size + '%', st.strength);
                    numFilters++;
                }

            } else if (itm.link == 'bal') {

                if (st.red !== 0 || st.green !== 0 || st.blue !== 0) {
                    editor.channels({
                        red: st.red,
                        green: st.green,
                        blue: st.blue
                    });
                    numFilters++;
                }

            } else if (itm.link == 'levels') {

                var changed = 0;
                _.each(itm.adjustments, function(adj) {
                    if (st[adj[0]] !== original[adj[0]]) changed = 1;
                });

                if (changed) {
                    editor.curves('rgb', [50, st.blacks], [80, st.shadows], [180, st.midtones], [255, st.highlights]);
                    numFilters++;
                }

            } else if (itm.label != 'Crop' && itm.label != 'Rotate') {
                _.each(itm.adjustments, function(key) {
                    var fn = key[0] == 'blur' ? 'stackBlur' : key[0];
                    if (st[key[0]] !== 0) {
                        editor[fn](st[key[0]]);
                        numFilters++;
                    }
                });
            }
        });

        editor.render();

        return numFilters;

    }

    alreadySaving = 0;

    function saveFullRes() {

        if (!alreadySaving) {

            alreadySaving = 1;

            editor = Caman(data.image.data);

            setTimeout(function(){

                var numFilters = 0,
                    numFinished = 0,
                    nowSaving = 0;

                Caman.Event.listen("processStart", function(job) {
                    postMessage({
                        type: 'status',
                        message: "Applying " + job.name,
                        percent: numFilters && (numFinished / numFilters) * 100 || 0
                    });
                });

                numFilters = apply();

                Caman.Event.listen(editor, "processComplete", function(job) {

                    numFinished++;

                    var percent = numFilters && (numFinished / numFilters) * 100 || 0;

                    postMessage({
                        type: 'status',
                        message: "Finished " + job.name,
                        percent: percent
                    });

                    if(percent == 100) {
                        setTimeout(function(){
                            editor = undefined;
                            data = undefined;
                        }, 500);
                    }

                });

            }, 500);
        }
    }

    saveFullRes();

}
EmanH commented 5 years ago

I think I used this version of CamanJS:

https://github.com/zaak/zaak.github.io/tree/master/CamanJS-demo

EmanH commented 5 years ago

Not sure if I would recommend this though. Uploading uncompressed image data is slow. Probably could get a JavaScript jpg library to compress it before save, but I haven't looked into that.

jocooler commented 5 years ago

@EmanH - maybe you could (optionally) post back to the main thread and draw on an offscreen canvas - then you can at least use PNG.