0

I'm trying to create a Firebase Function that allows me to pass an array of image URLs in to create generate a montage, upload the file to Firebase Storage and then return the generated Download URL. This will be called from my app, so I'm using functions.https.onCall.

const functions = require("firebase-functions");
const admin = require('firebase-admin');
var gm = require('gm').subClass({imageMagick: true});
admin.initializeApp();


exports.createMontage = functions.https.onCall((data, context) => {
var storageRef = admin.storage().bucket( 'gs://xyz-zyx.appspot.com' );
var createdMontage = storageRef.file('createdMontage.jpg');
function generateMontage(list){
  let g = gm()
   list.forEach(function(p){
       g.montage(p);
   })
   g.geometry('+81+81')
   g.density(5000,5000)
   .write(createdMontage, function(err) {
       if(!err) console.log("Written montage image.");
   });
   return true
}

generateMontage(data)

return createdMontage.getDownloadURL();

});

The function generateMontage() works locally on NodeJs (with a local write destination).

Thank you.

Ezra Butler
  • 120
  • 2
  • 13
  • I think you should check this related question on [SO](https://stackoverflow.com/questions/47810166/cloud-storage-for-firebase-access-error-admin-storage-ref-is-not-a-functio). It is mentioned that the Storage Object you are trying to access doesn't have the ref() functions, only app and bucket(). – marian.vladoi Jan 05 '21 at 10:30
  • I've tried `bucket()`. The first link is dead in the answer. When I clicked through the API and found this: https://cloud.google.com/storage/docs/samples/storage-upload-file, this 1) doesn't seem to be going through `admin` and 2) this means that I have to name and upload a file. I'm not sure how to `.write(filename)` – Ezra Butler Jan 05 '21 at 14:32
  • Will the file be in a public folder? As in `allow read;` as its storage rule. – Stratubas Jan 09 '21 at 23:53
  • I'm able to post in general. Just not in this. It's not a rules issue. – Ezra Butler Jan 09 '21 at 23:57

3 Answers3

2

Have a look at this example from the docs:

https://cloud.google.com/storage/docs/uploading-objects#storage-upload-object-code-sample

2021-01-11 Update

Here's a working example. I'm using regular Cloud Functions and it's limited in that the srcObject, dstObject and bucketName are constants but, it does create montages which is your goal.

PROJECT=[[YOUR-PROJECT]]
BILLING=[[YOUR-BILLING]]
REGION=[[YOUR-REGION]]

FUNCTION=[[YOUR-FUNCTION]]

BUCKET=[[YOUR-BUCKET]]
OBJECT=[[YOUR-OBJECT]] # Path from ${BUCKET} root

gcloud projects create ${PROJECT}

gcloud beta billing projects link ${PROJECT} \
--billing-account=${BILLING}

gcloud services enable cloudfunctions.googleapis.com \
--project=${PROJECT}

gcloud services enable cloudbuild.googleapis.com \
--project=${PROJECT}

gcloud functions deploy ${FUNCTION} \
--memory=4gib \
--max-instances=1
--allow-unauthenticated \
--entry-point=montager \
--set-env-vars=BUCKET=${BUCKET},OBJECT=${OBJECT} \
--runtime=nodejs12 \
--trigger-http \
--project=${PROJECT} \
--region=${REGION}

ENDPOINT=$(\
  gcloud functions describe ${FUNCTION} \
  --project=${PROJECT} \
  --region=${REGION} \
  --format="value(httpsTrigger.url)")

curl \
--request GET \
${ENDPOINT}


`package.json`:
```JSON
{
  "name": "montage",
  "version": "0.0.1",
  "dependencies": {
    "@google-cloud/storage": "5.7.1",
    "gm": "^1.23.1"
  }
}

And index.js:

const { Storage } = require('@google-cloud/storage');
const storage = new Storage();

const gm = require('gm').subClass({ imageMagick: true });

const bucketName = process.env["BUCKET"];
const srcObject = process.env["OBJECT"];
const dstObject = "montage.png";

// Creates 2x2 montage
const list = [
  `/tmp/${srcObject}`,
  `/tmp/${srcObject}`,
  `/tmp/${srcObject}`,
  `/tmp/${srcObject}`
];

const montager = async (req, res) => {
  // Download GCS `srcObject` to `/tmp`
  const f = await storage
    .bucket(bucketName)
    .file(srcObject)
    .download({
      destination: `/tmp/${srcObject}`
    });

  // Creating GCS write stream for montage
  const obj = await storage
    .bucket(bucketName)
    .file(dstObject)
    .createWriteStream();

  let g = gm();
  list.forEach(f => {
    g.montage(f);
  });

  console.log(`Returning`);
  g
    .geometry('+81+81')
    .density(5000, 5000)
    .stream()
    .pipe(obj)
    .on(`finish`, () => {
      console.log(`finish`);
      res.status(200).send(`ok`);
    })
    .on(`error`, (err) => {
      console.log(`error: ${err}`);
      res.status(500).send(`uhoh!`);
    });
}
exports.montager = montager;
DazWilkin
  • 32,823
  • 5
  • 47
  • 88
  • Yes, I have. It still doesn't answer my question. I'm able to upload things normally. Is your suggestion to first create a `const montage;`, then `.write()` in the function, then upload that file? – Ezra Butler Jan 09 '21 at 22:22
  • I misunderstood because you said the local write works and assumed you couldn't write the result to Cloud Storage. You'll need to create the file in memory but you can then use a stream method to create the GCS object. I wrote [this](https://medium.com/google-cloud/google-cloud-storage-exploder-221c5b4d219c) some time so but the principle should still work. See `index.js` starting at line #34. Will look up the method for you too. – DazWilkin Jan 10 '21 at 00:18
  • https://googleapis.dev/nodejs/storage/latest/File.html#createWriteStream – DazWilkin Jan 10 '21 at 00:25
  • Thank you, your piece clarifies things a bit. I'm still trying to wrap my head around the `createReadStream` and`createWriteStream` and the function at your line 27 , but it seems (from a link in your piece) like I need to create a temp dir with `const tempFilePath = path.join(os.tmpdir(), fileName);`. Write to that, upload, and unlink. Is the read/write stream just syntactic sugar that does the same thing? – Ezra Butler Jan 10 '21 at 00:40
  • In my example, I started from a GCS object zipped file, processed the file (unzipping) then wrote the unzipped content to the output bucket. You should be able to create your montage in memory and stream that directly to a GCS object (file). You can write to a temp directory but you shouldn't need to do so. – DazWilkin Jan 10 '21 at 17:19
  • I'm sorry, I'm even more thoroughly confused than when I began. I understand theoretically how the streams work, I just have a mental block in applying them to here. I've been at this particular script, which this code is only a part of, for more than a month, and now every attempt to run this is giving me a different error 500. – Ezra Butler Jan 10 '21 at 20:25
  • No need to apologize. Sorry I've not been able to explain sufficiently well. You currently use `gm().write(...)` which creates a file. You would then need to read this file back in to memory to write it out to GCS. I'm proposing that you replace `.write(...)` with `.stream(...)` something along the lines (not tried this) of `.stream(function (err, stdout, stderr) { var obj = gcs.bucket(bucketName)..file(filePath).createWriteStream(); stdout.pipe(obj); })`. See the [Streams](https://github.com/aheckmann/gm#streams) docs for `gm` – DazWilkin Jan 10 '21 at 20:58
  • If it's still unclear, I can find some time tomorrow morning to write a full example for you. – DazWilkin Jan 10 '21 at 20:59
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/227125/discussion-between-ezra-butler-and-dazwilkin). – Ezra Butler Jan 10 '21 at 21:18
0

I have never used 'gm', but, according to its npm page, it has a toBuffer function.

So maybe something like this could work:

const functions = require('firebase-functions');
const admin = require('firebase-admin');
const gm = require('gm').subClass({ imageMagick: true });
admin.initializeApp();

exports.createMontage = functions.https.onCall((data, _context) => {
  const bucketName = 'xyz-zyx'; // not sure, I've always used the default bucket
  const bucket = admin.storage().bucket(bucketName);
  const storagePath = 'createdMontage.jpg';
  const fileRef = bucket.file(storagePath);
  const generateMontage = async (list) => {
    const g = gm();
    list.forEach(function (p) {
      g.montage(p);
    });
    g.geometry('+81+81');
    g.density(5000, 5000);
    return new Promise(resolve => {
      g.toBuffer('JPG', (_err, buffer) => {
        const saveTask = fileRef.save(buffer, { contentType: 'image/jpeg' });
        const baseStorageUrl = `https://firebasestorage.googleapis.com/v0/b/${bucket.name}/o/`;
        const encodedPath = encodeURIComponent(storagePath);
        const postfix = '?alt=media'; // see stackoverflow.com/a/58443247/6002078
        const publicUrl = baseStorageUrl + encodedPath + postfix;
        saveTask.then(() => resolve(publicUrl));
      });
    });
  };
  return generateMontage(data);
});

But it seems it can be done more easily. As Methkal Khalawi commented:

here is a full example on how to use ImageMagic with Functions. Though they are using it for blurring an image but the idea is the same. And here is a tutorial from the documentation.

Stratubas
  • 2,939
  • 1
  • 13
  • 18
  • Thank you for this, but this code keeps on giving me errors that I'm not exactly sure how to handle - they want an error handler after the .saveTask.then(()). – Ezra Butler Jan 10 '21 at 20:40
  • @ezrabutler who's "they"? Try adding `.catch(console.error)` at that line. – Stratubas Jan 10 '21 at 21:04
  • Google Functions Logs. I had added `.catch((error) => {console.error(error);});` Before, and I must have changed the bucket name, but I'm actually seeing a file being created and placed into the bucket, however, it's empty, ie the montage isn't being created. And I've hardcoded four image URLs in a const list = [], to try to keep the variables as limited as possible.Will see if there is a for that. – Ezra Butler Jan 10 '21 at 21:30
  • The docs say `gm.montage()` only accepts file paths. You put external URLs? Does that work in a local node server? – Stratubas Jan 10 '21 at 21:48
  • I've never done anything similar, but it seems you can download files temporarily locally at the function and use them as local files. See https://github.com/firebase/functions-samples/blob/master/generate-thumbnail/functions/index.js – Stratubas Jan 10 '21 at 21:53
  • Apparently. I just tried all the different permutations of https:/ gs:/ and also just ./images. Thank you -- will let you know. – Ezra Butler Jan 10 '21 at 22:02
  • 1
    here is a [full example on how to use ImageMagic with Functions](https://github.com/GoogleCloudPlatform/nodejs-docs-samples/blob/master/functions/imagemagick/index.js). Thought they are using it for blurring an image but the idea is the same. and here is a [tutorial](https://cloud.google.com/functions/docs/tutorials/imagemagick#functions-prepare-environment-nodejs) from the documentation – Methkal Khalawi Jan 11 '21 at 10:05
  • @EzraButler see the new stuff – Stratubas Jan 11 '21 at 10:24
  • I've seen this tutorial / code so many times over the past month - maybe it's the fact that I need to be keeping multiple images in the temp directory, but it's not working. I finally got the file paths and temp files working, but it was basically just creating an empty image. – Ezra Butler Jan 11 '21 at 17:04
0

I think you can pipe output stream from gm module to firebase storage object write stream.

const functions = require("firebase-functions");
const admin = require('firebase-admin');
var gm = require('gm').subClass({imageMagick: true});
admin.initializeApp();

exports.createMontage = functions.https.onCall(async (data, context) => {
   var storage = admin.storage().bucket( 'gs://xyz-zyx.appspot.com' );
   
   var downloadURL = await new Promise((resolve, reject) => {
         let g = gm()
         list.forEach(function(p){
             g.montage(p);
         })
         g.geometry('+81+81')
         g.density(5000,5000)
          .stream((err, stdout, stderr) => {
              if (err) {
                  reject();
              }
              stdout.pipe(
                  storage.file('generatedMotent.png).createWriteStream({
                    metadata: {
                        contentType: 'image/png',
                    },
                })
              ).on('finish', () => {
                storage
                    .file('generatedMotent')
                    .getSignedUrl({
                        action: 'read',
                        expires: '03-09-2491', // Non expring public url
                    })
                    .then((url) => {
                        resolve(url);
                    });
              });
         })
   });

   return downloadURL;
});

FYI, Firebase Admin SDK storage object does not have getDownloadURL() function. You should generate non-expiring public signed URL from the storage object.

In addition to, it should cause another problem after some period of time according to this issue. To get rid of this issue happening, you should initialize firebase app with permanent service account.

const admin = require('firebase-admin');
const serviceAccount = require('../your-service-account.json');

admin.initializeApp({
    credential: admin.credential.cert(serviceAccount),
    projectId: JSON.parse(process.env.FIREBASE_CONFIG).projectId,
    databaseURL: JSON.parse(process.env.FIREBASE_CONFIG).databaseURL,
    storageBucket: JSON.parse(process.env.FIREBASE_CONFIG).storageBucket,
});
Richard Zhan
  • 460
  • 3
  • 10
  • This won't work because GM does not take URLs, so you need to download the images to a local temporary directory, and then run those temp files through GM, which proved to be the most difficult part of this. But thank you. – Ezra Butler Jan 16 '21 at 19:19