@live-change/image-service 0.2.32 → 0.2.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ const definition = require('./definition.js')
2
+
3
+ const config = definition.config
4
+ const uploadConfig = config.upload || {}
5
+
6
+ const {
7
+ maxUploadSize = 10 * 1024 * 1024,
8
+ maxUploadWidth = 2048,
9
+ maxUploadHeight = 2048,
10
+ maxProcessableSize = 50 * 1024 * 1025,
11
+ maxProcessableWidth = 10000,
12
+ maxProcessableHeight = 10000,
13
+ maxProcessablePixels = 6000*6000
14
+ } = uploadConfig
15
+
16
+ definition.clientConfig = {
17
+ maxUploadSize,
18
+ maxUploadWidth,
19
+ maxUploadHeight,
20
+ maxProcessableSize,
21
+ maxProcessableWidth,
22
+ maxProcessableHeight,
23
+ maxProcessablePixels
24
+ }
package/endpoint.js CHANGED
@@ -1,8 +1,151 @@
1
1
  const app = require("@live-change/framework").app()
2
2
  const definition = require('./definition.js')
3
3
 
4
- const storageDir = `./storage/images/`
5
- const uploadsDir = `./uploads/`
4
+ const { Image } = require('./image.js')
5
+
6
+ const fs = require("fs")
7
+ const path = require("path")
8
+ const sharp = require('sharp')
9
+
10
+ const config = definition.config
11
+
12
+ const imagesPath = config.imagesPath || `./storage/images/`
13
+
14
+ function normalizeFormat(f1) {
15
+ f1 = f1.toLowerCase().trim()
16
+ if(f1 == 'jpg') f1 = 'jpeg'
17
+ return f1
18
+ }
19
+
20
+ function isFormatsIdentical(f1, f2) {
21
+ return normalizeFormat(f1) == normalizeFormat(f2)
22
+ }
23
+
24
+ function fileExists(fn) {
25
+ return new Promise((resolve, reject) => {
26
+ fs.access(fn, fs.F_OK, (err) => {
27
+ resolve(!err)
28
+ })
29
+ })
30
+ }
31
+
32
+ function delay(ms) {
33
+ return new Promise((resolve, reject) => setTimeout(resolve, ms))
34
+ }
35
+ async function getImageMetadata(image, version) {
36
+ for(let t = 0; t < 5; t++) {
37
+ const metadata = await Image.get(image)
38
+ console.log("MD", metadata)
39
+ if(metadata) return metadata
40
+ await delay(200 * Math.pow(2, t))
41
+ }
42
+ return null
43
+ }
44
+
45
+ function sanitizeImageId(id) {
46
+ return id.replace(/[^a-zA-Z0-9\[\]@\.-]/g,"_")
47
+ }
48
+
49
+ async function handleImageGet(req, res, params) {
50
+ const { image } = params
51
+ const metadata = await getImageMetadata(image)
52
+ console.log("PIC METADATA", image, "=>", metadata)
53
+ if(!metadata) {
54
+ res.status(404)
55
+ res.send("Image " + image + " not found")
56
+ return
57
+ }
58
+ const imagePrefix = imagesPath + sanitizeImageId(image) + '/'
59
+ const sourceFilePath = path.resolve(imagePrefix + 'original.' + metadata.extension)
60
+ console.log("SOURCE IMAGE PATH", sourceFilePath)
61
+ if(!(await fileExists(sourceFilePath))) {
62
+ res.status(404)
63
+ console.error("IMAGE FILE NOT FOUND", sourceFilePath)
64
+ res.send("Image file not found")
65
+ return
66
+ }
67
+
68
+ const kernel = "lanczos3"
69
+
70
+ switch(params.type) {
71
+ case "original": {
72
+ if(params.format && !isFormatsIdentical(params.format, metadata.extension)) {
73
+ console.log("CONVERTING IMAGE!", metadata.extension, params.format)
74
+ const normalized = normalizeFormat(params.format)
75
+ const convertedFilePath = path.resolve(imagePrefix + 'converted.' + normalized)
76
+ if(!(await fileExists(convertedFilePath))) {
77
+ await sharp(sourceFilePath).toFile(convertedFilePath)
78
+ }
79
+ res.sendFile(convertedFilePath)
80
+ } else res.sendFile(sourceFilePath)
81
+ } break;
82
+ case "width": {
83
+ const width = +params.width
84
+ if(!(width > 0)) {
85
+ res.status(400)
86
+ res.send("Bad parameter value")
87
+ return
88
+ }
89
+ //if(width > metadata.original.width) return res.sendFile(sourceFilePath)
90
+ const normalized = normalizeFormat(params.format || metadata.extension)
91
+ const convertedFilePath = path.resolve(imagePrefix + `width-${width}.${normalized}`)
92
+ if(!(await fileExists(convertedFilePath))) {
93
+ await sharp(sourceFilePath)
94
+ .resize({
95
+ width,
96
+ kernel
97
+ })
98
+ .toFile(convertedFilePath)
99
+ }
100
+ res.sendFile(convertedFilePath)
101
+ } break;
102
+ case "height": {
103
+ const height = +params.height
104
+ if(!(height > 0)) {
105
+ res.status(400)
106
+ res.send("Bad parameter value")
107
+ return
108
+ }
109
+ //if(height > metadata.original.height) return res.sendFile(sourceFilePath)
110
+ const normalized = normalizeFormat(params.format || metadata.extension)
111
+ const convertedFilePath = path.resolve(imagePrefix + `height-${height}.${normalized}`)
112
+ if(!(await fileExists(convertedFilePath))) {
113
+ await sharp(sourceFilePath)
114
+ .resize({
115
+ height,
116
+ kernel
117
+ })
118
+ .toFile(convertedFilePath)
119
+ }
120
+ res.sendFile(convertedFilePath)
121
+ } break;
122
+ case "rect": {
123
+ const width = +params.width
124
+ const height = +params.height
125
+ if(!(height > 0 && width>0)) {
126
+ res.status(400)
127
+ res.send("Bad parameter value")
128
+ return
129
+ }
130
+ //if(height > metadata.original.height) return res.sendFile(sourceFilePath)
131
+ //if(width > metadata.original.width) return res.sendFile(sourceFilePath)
132
+ const normalized = normalizeFormat(params.format || metadata.extension)
133
+ const convertedFilePath = path.resolve(imagePrefix + `rect-${width}-${height}.${normalized}`)
134
+ if(!(await fileExists(convertedFilePath))) {
135
+ await sharp(sourceFilePath)
136
+ .resize({
137
+ width, height,
138
+ fit: sharp.fit.cover,
139
+ position: sharp.strategy.attention,
140
+ kernel
141
+ })
142
+ .toFile(convertedFilePath)
143
+ }
144
+ res.sendFile(convertedFilePath)
145
+ }
146
+ }
147
+ }
148
+
6
149
 
7
150
  const express = require("express")
8
151
 
@@ -10,39 +153,34 @@ definition.endpoint({
10
153
  name: 'image',
11
154
  create() {
12
155
  const expressApp = express()
13
- expressApp.get('/:image/:version/width-:width.:format', (req, res) => handleImageGet(req, res, {
14
- ...req.params,
15
- type: "width"
16
- }))
17
- expressApp.get('/:image/:version/width-:width', (req, res) => handleImageGet(req, res, {
18
- ...req.params,
19
- type: "width"
20
- }))
21
- expressApp.get('/:image/:version/height-:height.:format', (req, res) => handleImageGet(req, res, {
22
- ...req.params,
23
- type: "height"
24
- }))
25
- expressApp.get('/:image/:version/height-:height', (req, res) => handleImageGet(req, res, {
26
- ...req.params,
27
- type: "height"
28
- }))
29
- expressApp.get('/:image/:version/rect-:width-:height.:format', (req, res) => handleImageGet(req, res, {
30
- ...req.params,
31
- type: "rect"
32
- }))
33
- expressApp.get('/:image/:version/rect-:width-:height', (req, res) => handleImageGet(req, res, {
34
- ...req.params,
35
- type: "rect"
36
- }))
37
- expressApp.get('/:image/:version.:format', (req, res) => handleImageGet(req, res, {
38
- ...req.params,
39
- type: "auto"
40
- }))
41
- expressApp.get('/:image/:version', (req, res) => handleImageGet(req, res, {
42
- ...req.params,
43
- type: "auto"
44
- }))
45
-
156
+ expressApp.get('/:image/width-:width.:format',
157
+ (req, res) => handleImageGet(req, res, { ...req.params, type: "width" })
158
+ )
159
+ expressApp.get('/:image/width-:width',
160
+ (req, res) => handleImageGet(req, res, { ...req.params, type: "width" })
161
+ )
162
+ expressApp.get('/:image/height-:height.:format',
163
+ (req, res) => handleImageGet(req, res, { ...req.params, type: "height" })
164
+ )
165
+ expressApp.get('/:image/height-:height',
166
+ (req, res) => handleImageGet(req, res, { ...req.params, type: "height" })
167
+ )
168
+ expressApp.get('/:image/rect-:width-:height.:format',
169
+ (req, res) => handleImageGet(req, res, { ...req.params, type: "rect" })
170
+ )
171
+ expressApp.get('/:image/rect-:width-:height',
172
+ (req, res) => handleImageGet(req, res, { ...req.params, type: "rect" })
173
+ )
174
+ expressApp.get('/:image/.:format',
175
+ (req, res) => handleImageGet(req, res, { ...req.params, type: "original" })
176
+ )
177
+ expressApp.get('/:image',
178
+ (req, res) => handleImageGet(req, res, { ...req.params, type: "original" })
179
+ )
180
+ expressApp.use('*', async (req, res) => {
181
+ res.writeHead(200, { "Content-Type": "text/plain" })
182
+ res.end("IMAGE!")
183
+ })
46
184
  return expressApp
47
185
  }
48
186
  })
package/fsUtils.js CHANGED
@@ -1,3 +1,4 @@
1
+ const fs = require('fs')
1
2
 
2
3
  function move(from, to) {
3
4
  return new Promise((resolve,reject) => {
package/image.js CHANGED
@@ -1,8 +1,22 @@
1
1
  const app = require("@live-change/framework").app()
2
2
  const definition = require('./definition.js')
3
+ const config = definition.config
3
4
 
4
- const storageDir = `./storage/images/`
5
- const uploadsDir = `./uploads/`
5
+ const imagesPath = config.imagesPath || `./storage/images/`
6
+ const uploadsPath = config.uploadsPath || `./storage/uploads/`
7
+
8
+ const cropInfo = {
9
+ type: Object,
10
+ properties: {
11
+ originalImage: {
12
+ type: String
13
+ },
14
+ x: { type: Number },
15
+ y: { type: Number },
16
+ zoom: { type: Number, defaultValue: 1 },
17
+ orientation: { type: Number }
18
+ }
19
+ }
6
20
 
7
21
  const Image = definition.model({
8
22
  name: "Image",
@@ -15,145 +29,56 @@ const Image = definition.model({
15
29
  fileName: {
16
30
  type: String
17
31
  },
18
- original: {
19
- type: Object,
20
- properties: {
21
- width: { type: Number },
22
- height: { type: Number },
23
- extension: { type: String }
24
- }
32
+ width: {
33
+ type: Number,
34
+ validation: ['nonEmpty']
25
35
  },
26
- crop: {
27
- type: Object,
28
- properties: {
29
- x: { type: Number },
30
- y: { type: Number },
31
- width: { type: Number },
32
- height: { type: Number },
33
- zoom: { type: Number, defaultValue: 1 },
34
- orientation: {type: Number}
35
- }
36
+ height: {
37
+ type: Number,
38
+ validation: ['nonEmpty']
39
+ },
40
+ extension: {
41
+ type: String,
42
+ validation: ['nonEmpty']
36
43
  },
37
44
  purpose: {
38
45
  type: String,
39
46
  validation: ['nonEmpty']
40
- }
47
+ },
48
+ crop: cropInfo
41
49
  }
42
50
  })
43
51
 
44
52
  const { move, copy, mkdir, rmdir } = require('./fsUtils')
53
+ const fs = require('fs')
45
54
  const sharp = require('sharp')
46
55
  const download = require('download')
47
56
 
48
- definition.action({
49
- name: "createEmptyImage",
50
- properties: {
51
- name: {
52
- type: String,
53
- validation: ['nonEmpty']
54
- },
55
- purpose: {
56
- type: String,
57
- validation: ['nonEmpty']
58
- },
59
- ownerType: {
60
- type: String,
61
- validation: ['nonEmpty']
62
- },
63
- owner: {
64
- type: String,
65
- validation: ['nonEmpty']
66
- }
67
- },
68
- /// TODO: accessControl
69
- async execute({ name, purpose, owner, ownerType }, { client, service }, emit) {
70
- const image = app.generateUid()
71
-
72
- const dir = `${storageDir}${image}`
73
-
74
- emit({
75
- type: "ownerOwnedImageCreated",
76
- image,
77
- identifiers: {
78
- owner, ownerType
79
- },
80
- data: {
81
- name, purpose,
82
- fileName: null,
83
- original: null,
84
- crop: null
85
- }
86
- })
87
-
88
- await mkdir(dir)
89
- await mkdir(`${dir}/originalCache`)
90
- await mkdir(`${dir}/cropCache`)
57
+ fs.mkdirSync(imagesPath, { recursive: true })
91
58
 
92
- return image
93
- }
94
- })
59
+ const Upload = definition.foreignModel('upload', 'Upload')
95
60
 
96
61
  definition.action({
97
- name: "uploadImage",
62
+ name: "createImage",
98
63
  properties: {
99
64
  image: {
100
65
  type: Image
101
66
  },
102
- original: {
103
- type: Object,
104
- properties: {
105
- width: { type: Number },
106
- height: { type: Number },
107
- uploadId: { type: String }
108
- }
109
- }
110
- },
111
- /// TODO: accessControl!
112
- waitForEvents: true,
113
- async execute({ image, original }, { client, service }, emit) {
114
- const upload = await app.dao.get(['database', 'tableObject', app.databaseName, 'uploads', original.uploadId])
115
- if(!upload) throw new Error("upload_not_found")
116
- if(upload.state!='done') throw new Error("upload_not_done")
117
-
118
- let extension = upload.fileName.match(/\.([A-Z0-9]+)$/i)[1].toLowerCase()
119
- if(extension == 'jpg') extension = "jpeg"
120
- const dir = `${storageDir}${image}`
121
-
122
- emit({
123
- type: "ownerOwnedImageUpdated",
124
- image,
125
- data: {
126
- fileName: upload.fileName,
127
- original: {
128
- width: original.width,
129
- height: original.height,
130
- extension
131
- },
132
- crop: null
133
- }
134
- })
135
-
136
- await move(`${uploadsDir}${upload.id}`, `${dir}/original.${extension}`)
137
- await app.dao.request(['database', 'delete'], app.databaseName, 'uploads', upload.id)
138
-
139
- return image
140
- }
141
- })
142
-
143
- definition.action({
144
- name: "createImage",
145
- properties: {
146
67
  name: {
147
68
  type: String,
148
69
  validation: ['nonEmpty']
149
70
  },
150
- original: {
151
- type: Object,
152
- properties: {
153
- width: { type: Number },
154
- height: { type: Number },
155
- uploadId: { type: String }
156
- }
71
+ width: {
72
+ type: Number,
73
+ validation: ['nonEmpty']
74
+ },
75
+ height: {
76
+ type: Number,
77
+ validation: ['nonEmpty']
78
+ },
79
+ upload: {
80
+ type: Upload,
81
+ validation: ['nonEmpty']
157
82
  },
158
83
  purpose: {
159
84
  type: String,
@@ -166,20 +91,26 @@ definition.action({
166
91
  owner: {
167
92
  type: String,
168
93
  validation: ['nonEmpty']
169
- }
94
+ },
95
+ crop: cropInfo
170
96
  },
171
97
  /// TODO: accessControl!
172
98
  waitForEvents: true,
173
- async execute({ name, original, purpose }, { client, service }, emit) {
174
- const image = app.generateUid()
175
- const upload = await app.dao.get(['database', 'tableObject', app.databaseName, 'uploads', original.uploadId])
176
-
177
- if(!upload) throw new Error("upload_not_found")
178
- if(upload.state!='done') throw new Error("upload_not_done")
99
+ async execute({ image, name, width, height, upload, purpose, owner, ownerType, crop }, { client, service }, emit) {
100
+ if(!image) {
101
+ image = app.generateUid()
102
+ } else {
103
+ // TODO: check id source session
104
+ const existing = await Image.get(image)
105
+ if (existing) throw 'already_exists'
106
+ }
107
+ const uploadRow = await Upload.get(upload)
108
+ if(!uploadRow) throw new Error("upload_not_found")
109
+ if(uploadRow.state!='done') throw new Error("upload_not_done")
179
110
 
180
- let extension = upload.fileName.match(/\.([A-Z0-9]+)$/i)[1].toLowerCase()
111
+ let extension = uploadRow.fileName.match(/\.([A-Z0-9]+)$/i)[1].toLowerCase()
181
112
  if(extension == 'jpg') extension = "jpeg"
182
- const dir = `${storageDir}${image}`
113
+ const dir = `${imagesPath}${image}`
183
114
 
184
115
  emit({
185
116
  type: "ownerOwnedImageCreated",
@@ -188,116 +119,21 @@ definition.action({
188
119
  owner, ownerType
189
120
  },
190
121
  data: {
191
- name,
192
- purpose,
193
- fileName: upload.fileName,
194
- original: {
195
- width: original.width,
196
- height: original.height,
197
- extension
198
- },
199
- crop: null,
200
- owner: client.user
122
+ name, purpose,
123
+ fileName: uploadRow.fileName,
124
+ width, height, extension, crop
201
125
  }
202
126
  })
203
127
 
204
128
  await mkdir(dir)
205
- await mkdir(`${dir}/originalCache`)
206
- await mkdir(`${dir}/cropCache`)
207
- await move(`${uploadsDir}${upload.id}`, `${dir}/original.${extension}`)
208
- await app.dao.request(['database', 'delete'], app.databaseName, 'uploads', upload.id)
209
-
210
- return image
211
- }
212
- })
213
-
214
- definition.action({
215
- name: "cropImage",
216
- properties: {
217
- image: {
218
- type: Image
219
- },
220
- crop: {
221
- type: Object,
222
- properties: {
223
- x: {type: Number},
224
- y: {type: Number},
225
- width: {type: Number},
226
- height: {type: Number},
227
- zoom: {type: Number, defaultValue: 1},
228
- orientation: {type: Number}
229
- }
230
- },
231
- uploadId: {type: String}
232
- },
233
- /// TODO: accessControl!
234
- waitForEvents: true,
235
- async execute({ image, crop, uploadId }, {client, service}, emit) {
236
- const imageRow = await Image.get(image)
237
- if(!imageRow) throw new Error("not_found")
238
-
239
- const upload = await app.dao.get(['database', 'tableObject', app.databaseName, 'uploads', uploadId])
240
-
241
- console.log("UPLOAD CROP", upload)
242
-
243
- if(!upload) throw new Error("upload_not_found")
244
- if(upload.state != 'done') throw new Error("upload_not_done")
245
-
246
- console.log("CURRENT IMAGE ROW", image, imageRow)
247
- if(!imageRow.crop) { // first crop
248
- const dir = `${storageDir}${image}`
249
- let extension = upload.fileName.match(/\.([A-Z0-9]+)$/i)[1].toLowerCase()
250
- if(extension == 'jpg') extension = "jpeg"
251
-
252
- await move(`${uploadsDir}${upload.id}`, `${dir}/crop.${extension}`)
253
- await app.dao.request(['database', 'delete'], app.databaseName, 'uploads', upload.id)
254
-
255
- emit({
256
- type: "ownerOwnedImageUpdated",
257
- image,
258
- data: {
259
- crop
260
- }
261
- })
262
-
263
- return image
264
- } else { // next crop - need to copy image
265
- const newImage = app.generateUid()
266
-
267
- const dir = `${storageDir}${image}`
268
- const newDir = `${storageDir}${newImage}`
129
+ await move(`${uploadsPath}${uploadRow.id}`, `${dir}/original.${extension}`)
269
130
 
270
- await mkdir(newDir)
271
- await mkdir(`${newDir}/originalCache`)
272
- await mkdir(`${newDir}/cropCache`)
273
- await move(`${dir}/original.${imageRow.original.extension}`,
274
- `${newDir}/original.${imageRow.original.extension}`)
275
-
276
- let extension = upload.fileName.match(/\.([A-Z0-9]+)$/i)[1].toLowerCase()
277
- if(extension == 'jpg') extension = "jpeg"
278
-
279
- await move(`../../storage/uploads/${upload.id}`, `${newDir}/crop.${extension}`)
280
- await app.dao.request(['database', 'delete'], app.databaseName, 'uploads', upload.id)
281
-
282
- const { owner, ownerType } = imageRow
283
-
284
- emit({
285
- type: "ownerOwnedImageCreated",
286
- image,
287
- identifiers: {
288
- owner, ownerType
289
- },
290
- data: {
291
- name: imageRow.name,
292
- purpose: imageRow.purpose,
293
- fileName: upload.fileName,
294
- original: imageRow.original,
295
- crop
296
- }
297
- })
131
+ await app.trigger({
132
+ type: 'uploadUsed',
133
+ upload: uploadRow.id
134
+ })
298
135
 
299
- return newImage
300
- }
136
+ return image
301
137
  }
302
138
  })
303
139
 
@@ -330,62 +166,53 @@ definition.trigger({
330
166
  }
331
167
  },
332
168
  waitForEvents: true,
333
- async execute({ name, purpose, url, cropped, owner, ownerType }, { service, client }, emit) {
169
+ async execute({ name, purpose, url, owner, ownerType }, { service, client }, emit) {
334
170
  const image = app.generateUid()
335
171
 
336
- const downloadPath = `${uploadsDir}download_${image}`
337
- await download(url, uploadsDir, { filename: `download_${image}` })
172
+ const downloadPath = `${uploadsPath}download_${image}`
173
+ await download(url, uploadsPath, { filename: `download_${image}` })
338
174
 
339
175
  const metadata = await sharp(downloadPath).metadata()
340
176
 
341
- let data = {
342
- name,
343
- purpose,
344
- fileName: url.split('/').pop(),
345
- original: {
346
- width: metadata.width,
347
- height: metadata.height,
348
- extension: metadata.format
349
- },
350
- crop: null
351
- }
352
-
353
- if(cropped) {
354
- data.crop = {
355
- x: 0,
356
- y: 0,
357
- width: metadata.width,
358
- height: metadata.height,
359
- zoom: 1,
360
- orientation: 0
361
- }
362
- }
363
-
364
- emit({
365
- type: "ImageCreated",
366
- image,
367
- data
368
- })
369
-
370
177
  emit({
371
178
  type: "ownerOwnedImageCreated",
372
179
  image,
373
180
  identifiers: {
374
181
  owner, ownerType
375
182
  },
376
- data
183
+ data: {
184
+ name, purpose,
185
+ fileName: url.split('/').pop(),
186
+ width: metadata.width,
187
+ height: metadata.height,
188
+ extension: metadata.format,
189
+ crop: null
190
+ }
377
191
  })
378
192
 
379
- const dir = `${storageDir}/${image}`
193
+ const dir = `${imagesPath}/${image}`
380
194
 
381
195
  await mkdir(dir)
382
- await mkdir(`${dir}/originalCache`)
383
- await mkdir(`${dir}/cropCache`)
384
196
  await move(downloadPath, `${dir}/original.${metadata.format}`)
385
- if(cropped) await copy(`${dir}/original.${metadata.format}`, `${dir}/crop.${metadata.format}`)
386
197
 
387
198
  return image
388
199
  }
389
200
  })
390
201
 
202
+ definition.view({
203
+ name: 'image',
204
+ properties: {
205
+ image: {
206
+ type: Image,
207
+ validation: ['nonEmpty']
208
+ }
209
+ },
210
+ returns: {
211
+ type: Image
212
+ },
213
+ daoPath({ image }, { client, context }) {
214
+ return Image.path( image )
215
+ }
216
+ })
217
+
391
218
  module.exports = { Image }
package/index.js CHANGED
@@ -2,6 +2,8 @@ const app = require("@live-change/framework").app()
2
2
 
3
3
  const definition = require('./definition.js')
4
4
 
5
+ require('./clientConfig.js')
5
6
  require('./image.js')
7
+ require('./endpoint.js')
6
8
 
7
9
  module.exports = definition
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@live-change/image-service",
3
- "version": "0.2.32",
3
+ "version": "0.2.35",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -21,9 +21,11 @@
21
21
  "url": "https://www.viamage.com/"
22
22
  },
23
23
  "dependencies": {
24
- "@live-change/framework": "0.6.4",
25
- "@live-change/relations-plugin": "0.6.3",
26
- "pluralize": "8.0.0"
24
+ "@live-change/framework": "0.6.5",
25
+ "@live-change/relations-plugin": "0.6.5",
26
+ "download": "^8.0.0",
27
+ "pluralize": "8.0.0",
28
+ "sharp": "^0.30.6"
27
29
  },
28
- "gitHead": "91f89af637893c2dbc0e1f55b2886345d408a9ea"
30
+ "gitHead": "f7b39211ab2b1de3fa579aab77ae208a8a1f9da3"
29
31
  }