From 669a68d27b0a8ed34ff272a987685ad584e7534f Mon Sep 17 00:00:00 2001 From: Taye Adeyemi Date: Tue, 3 Dec 2024 15:16:56 +0100 Subject: [PATCH] feat: apply filters with WebGL --- package-lock.json | 34 ++ package.json | 1 + .../assets/js/components/ComposeModal.vue | 491 +++++++----------- resources/assets/js/components/filters.js | 290 +++++++++++ 4 files changed, 507 insertions(+), 309 deletions(-) create mode 100644 resources/assets/js/components/filters.js diff --git a/package-lock.json b/package-lock.json index ec29866a7..825688500 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "vue-loading-overlay": "^3.3.3", "vue-timeago": "^5.1.2", "vue-tribute": "^1.0.7", + "webgl-media-editor": "^0.0.1", "zuck.js": "^1.6.0" }, "devDependencies": { @@ -5775,6 +5776,12 @@ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" }, + "node_modules/gl-matrix": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.3.tgz", + "integrity": "sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA==", + "license": "MIT" + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -9971,6 +9978,15 @@ "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.1.tgz", "integrity": "sha512-x9v3H/lTKIJKQQe7RPQkLfKAnc9lUTkWDypIQgTzPJAq+5/GCDHonmshfvlsNSj58yyshbIJJDLmU15qNERrXQ==" }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, "node_modules/thunky": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", @@ -10048,6 +10064,12 @@ "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", "dev": true }, + "node_modules/twgl.js": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/twgl.js/-/twgl.js-5.5.4.tgz", + "integrity": "sha512-6kFOmijOpmblTN9CCwOTCxK4lPg7rCyQjLuub6EMOlEp89Ex6yUcsMjsmH7andNPL2NE3XmHdqHeP5gVKKPhxw==", + "license": "MIT" + }, "node_modules/twitter-text": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/twitter-text/-/twitter-text-2.0.5.tgz", @@ -10535,6 +10557,18 @@ "node": ">= 8" } }, + "node_modules/webgl-media-editor": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/webgl-media-editor/-/webgl-media-editor-0.0.1.tgz", + "integrity": "sha512-TxnuRl3rpWa1Cia/pn+vh+0iz3yDNwzsrnRGJ61YkdZAYuimu2afBivSHv0RK73hKza6Y/YoRCkuEcsFmtxPNw==", + "license": "AGPL-3.0-only", + "dependencies": { + "cropperjs": "^1.6.2", + "gl-matrix": "^3.4.3", + "throttle-debounce": "^5.0.2", + "twgl.js": "^5.5.4" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index 7724f040c..691972ced 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "vue-loading-overlay": "^3.3.3", "vue-timeago": "^5.1.2", "vue-tribute": "^1.0.7", + "webgl-media-editor": "^0.0.1", "zuck.js": "^1.6.0" }, "collective": { diff --git a/resources/assets/js/components/ComposeModal.vue b/resources/assets/js/components/ComposeModal.vue index 94f6f5e13..1ec98c64f 100644 --- a/resources/assets/js/components/ComposeModal.vue +++ b/resources/assets/js/components/ComposeModal.vue @@ -1,6 +1,6 @@ Post Post - Next @@ -342,8 +341,8 @@
-
- +
-
- -
-
-
- -
+ +

-
- +
+
@@ -427,7 +398,7 @@
- +
@@ -780,7 +751,7 @@
- +

{{video.title ? video.title.slice(0,70) : 'Untitled'}}

{{video.description ? video.description.slice(0,90) : 'No description'}}

@@ -839,13 +810,6 @@
- -
-
- -

Applying filters...

-
-
@@ -875,13 +839,17 @@ import 'cropperjs/dist/cropper.css'; import Autocomplete from '@trevoreyre/autocomplete-vue' import '@trevoreyre/autocomplete-vue/dist/style.css' import VueTribute from 'vue-tribute' +import { MediaEditor, MediaEditorPreview, MediaEditorFilterMenu } from 'webgl-media-editor/vue2' +import { filterEffects } from './filters'; export default { components: { VueCropper, Autocomplete, - VueTribute + VueTribute, + MediaEditorPreview, + MediaEditorFilterMenu }, data() { @@ -892,10 +860,9 @@ export default { composeText: '', composeTextLength: 0, nsfw: false, - filters: [], - currentFilter: false, ids: [], media: [], + files: [], carouselCursor: 0, uploading: false, uploadProgress: 100, @@ -923,7 +890,6 @@ export default { }, namedPages: [ - 'filteringMedia', 'cropPhoto', 'tagPeople', 'addLocation', @@ -1044,13 +1010,26 @@ export default { collectionsPage: 1, collectionsCanLoadMore: false, spoilerText: undefined, - isFilteringMedia: false, - filteringMediaTimeout: undefined, - filteringRemainingCount: 0, isPosting: false, } }, + created() { + this.editor = new MediaEditor({ + effects: filterEffects, + onEdit: (index, {effect, intensity, crop}) => { + if (index >= this.files.length) return + const file = this.files[index] + + this.$set(file, 'editState', { effect, intensity, crop }) + }, + onRenderPreview: (sourceIndex, previewUrl) => { + const media = this.media[sourceIndex] + if (media) media.preview_url = previewUrl + }, + }) + }, + computed: { spoilerTextLength: function() { return this.spoilerText ? this.spoilerText.length : 0; @@ -1058,7 +1037,6 @@ export default { }, beforeMount() { - this.filters = window.App.util.filters.sort(); axios.get('/api/compose/v0/settings') .then(res => { this.composeSettings = res.data; @@ -1075,8 +1053,12 @@ export default { }); }, - mounted() { - this.mediaWatcher(); + destroyed() { + this.files.forEach(fileInfo => { + URL.revokeObjectURL(fileInfo.url); + }) + this.files.length = this.media.length = 0 + this.editor = undefined }, methods: { @@ -1156,39 +1138,55 @@ export default { this.mode = 'text'; }, - mediaWatcher() { - let self = this; - $(document).on('change', '#pf-dz', function(e) { - self.mediaUpload(); - }); - }, + onInputFile(event) { + const input = event.target + const files = Array.from(input.files) + input.value = null; - mediaUpload() { let self = this; - self.uploading = true; - let io = document.querySelector('#pf-dz'); - if(!io.files.length) { - self.uploading = false; - } - Array.prototype.forEach.call(io.files, function(io, i) { - if(self.media && self.media.length + i >= self.config.uploader.album_limit) { + + files.forEach((file, i) => { + if(self.media && self.media.length >= self.config.uploader.album_limit) { swal('Error', 'You can only upload ' + self.config.uploader.album_limit + ' photos per album', 'error'); - self.uploading = false; self.page = 2; return; } - let type = io.type; let acceptedMimes = self.config.uploader.media_types.split(','); - let validated = $.inArray(type, acceptedMimes); + let validated = $.inArray(file.type, acceptedMimes); if(validated == -1) { swal('Invalid File Type', 'The file you are trying to add is not a valid mime type. Please upload a '+self.config.uploader.media_types+' only.', 'error'); - self.uploading = false; self.page = 2; return; } + const type = file.type.replace(/\/.*/, '') + const url = URL.createObjectURL(file) + const preview_url = type === 'image' ? url : '/storage/no-preview.png' + + this.files.push({ file, editState: undefined }) + this.media.push({ url, preview_url, type }) + }) + + if (this.media.length) { + this.page = 3 + } else { + this.page = 2 + } + }, + + async mediaUpload() { + this.uploading = true; + + const uploadPromises = this.files.map(async (fileInfo, i) => { + let file = fileInfo.file + const media = this.media[i] + + if (media.type === 'image' && fileInfo.editState) { + file = await this.editor.toBlob(i) + } + let form = new FormData(); - form.append('file', io); + form.append('file', file); let xhrConfig = { onUploadProgress: function(e) { @@ -1197,12 +1195,13 @@ export default { } }; - axios.post('/api/compose/v0/media/upload', form, xhrConfig) + const self = this + + await axios.post('/api/compose/v0/media/upload', form, xhrConfig) .then(function(e) { self.uploadProgress = 100; self.ids.push(e.data.id); - self.media.push(e.data); - self.uploading = false; + Object.assign(media, e.data) setTimeout(function() { // if(type === 'video/mp4') { // self.pageTitle = 'Edit Video Details'; @@ -1216,131 +1215,100 @@ export default { }).catch(function(e) { switch(e.response.status) { case 403: - self.uploading = false; - io.value = null; swal('Account size limit reached', 'Contact your admin for assistance.', 'error'); self.page = 2; break; case 413: - self.uploading = false; - io.value = null; - swal('File is too large', 'The file you uploaded has the size of ' + self.formatBytes(io.size) + '. Unfortunately, only images up to ' + self.formatBytes(self.config.uploader.max_photo_size * 1024) + ' are supported.\nPlease resize the file and try again.', 'error'); + swal('File is too large', 'The file you uploaded has the size of ' + self.formatBytes(file.size) + '. Unfortunately, only images up to ' + self.formatBytes(self.config.uploader.max_photo_size * 1024) + ' are supported.\nPlease resize the file and try again.', 'error'); self.page = 2; break; case 451: - self.uploading = false; - io.value = null; swal('Banned Content', 'This content has been banned and cannot be uploaded.', 'error'); self.page = 2; break; case 429: - self.uploading = false; - io.value = null; swal('Limit Reached', 'You can upload up to 250 photos or videos per day and you\'ve reached that limit. Please try again later.', 'error'); self.page = 2; break; case 500: - self.uploading = false; - io.value = null; swal('Error', e.response.data.message, 'error'); self.page = 2; break; default: - self.uploading = false; - io.value = null; swal('Oops, something went wrong!', 'An unexpected error occurred.', 'error'); self.page = 2; break; } + + throw e }); - io.value = null; - self.uploadProgress = 0; }); + + await Promise.all(uploadPromises).finally(() => { + this.uploadProgress = 0; + this.uploading = false; + }); }, - toggleFilter(e, filter) { - this.media[this.carouselCursor].filter_class = filter; - this.currentFilter = filter; - }, - - deleteMedia() { + async deleteMedia() { if(window.confirm('Are you sure you want to delete this media?') == false) { return; } let id = this.media[this.carouselCursor].id; - axios.delete('/api/compose/v0/media/delete', { - params: { - id: id - } - }).then(res => { - this.ids.splice(this.carouselCursor, 1); - this.media.splice(this.carouselCursor, 1); - if(this.media.length == 0) { - this.ids = []; - this.media = []; - this.carouselCursor = 0; - } else { - this.carouselCursor = 0; - } - }).catch(err => { - swal('Whoops!', 'An error occured when attempting to delete this, please try again', 'error'); - }); + if (id) { + try { + await axios.delete('/api/compose/v0/media/delete', { + params: { + id: id + } + }) + } + catch(err) { + swal('Whoops!', 'An error occured when attempting to delete this, please try again', 'error'); + return + } + } + this.ids.splice(this.carouselCursor, 1); + this.media.splice(this.carouselCursor, 1); + + URL.revokeObjectURL(this.files[this.carouselCursor]?.url) + this.files.splice(this.carouselCursor, 1) + + if(this.media.length == 0) { + this.ids = []; + this.media = []; + this.carouselCursor = 0; + } else { + this.carouselCursor = 0; + } }, mediaReorder(dir) { - const m = this.media; - const cur = this.carouselCursor; - const pla = m[cur]; - let res = []; - let cursor = 0; + const prevIndex = this.carouselCursor + const newIndex = prevIndex + (dir === 'prev' ? -1 : 1) - if(dir == 'prev') { - if(cur == 0) { - for (let i = cursor; i < m.length - 1; i++) { - res[i] = m[i+1]; - } - res[m.length - 1] = pla; - cursor = 0; - } else { - res = this.handleSwap(m, cur, cur - 1); - cursor = cur - 1; - } - } else { - if(cur == m.length - 1) { - res = m; - let lastItem = res.pop(); - res.unshift(lastItem); - cursor = m.length - 1; - } else { - res = this.handleSwap(m, cur, cur + 1); - cursor = cur + 1; - } - } - this.$nextTick(() => { - this.media = res; - this.carouselCursor = cursor; - }) + if (newIndex < 0 || newIndex >= this.media.length) return + + const [removedFile] = this.files.splice(prevIndex, 1) + const [removedMedia] = this.media.splice(prevIndex, 1) + const [removedId] = this.ids.splice(prevIndex, 1) + + this.files.splice(newIndex, 0, removedFile) + this.media.splice(newIndex, 0, removedMedia) + this.ids.splice(newIndex, 0, removedId) + this.carouselCursor = newIndex }, - handleSwap(arr, index1, index2) { - if (index1 >= 0 && index1 < arr.length && index2 >= 0 && index2 < arr.length) { - const temp = arr[index1]; - arr[index1] = arr[index2]; - arr[index2] = temp; - return arr; - } - }, - - compose() { + async compose() { let state = this.composeState; - if(this.uploadProgress != 100 || this.ids.length == 0) { + if(this.files.length == 0) { return; } @@ -1353,11 +1321,14 @@ export default { switch(state) { case 'publish': this.isPosting = true; - let count = this.media.filter(m => m.filter_class && !m.hasOwnProperty('is_filtered')).length; - if(count) { - this.applyFilterToMedia(); - return; + + try { + await this.mediaUpload().finally(() => this.isPosting = false) + } catch { + this.isPosting = false; + return } + if(this.composeSettings.media_descriptions === true) { let count = this.media.filter(m => { return !m.hasOwnProperty('alt') || m.alt.length < 2; @@ -1420,6 +1391,8 @@ export default { this.defineErrorMessage(err); break; } + }).finally(() => { + this.isPosting = false; }); return; break; @@ -1488,10 +1461,6 @@ export default { switch(this.mode) { case 'photo': switch(this.page) { - case 'filteringMedia': - this.page = 2; - break; - case 'addText': this.page = 1; break; @@ -1526,10 +1495,6 @@ export default { case 'video': switch(this.page) { - case 'filteringMedia': - this.page = 2; - break; - case 'licensePicker': this.page = 'video-2'; break; @@ -1550,10 +1515,6 @@ export default { this.page = 1; break; - case 'filteringMedia': - this.page = 2; - break; - case 'textOptions': this.page = 'addText'; break; @@ -1593,31 +1554,14 @@ export default { this.page = 2; break; - case 'filteringMedia': - break; - case 'cropPhoto': - this.pageLoading = true; - let self = this; - this.$refs.cropper.getCroppedCanvas({ - maxWidth: 4096, - maxHeight: 4096, - fillColor: '#fff', - imageSmoothingEnabled: false, - imageSmoothingQuality: 'high', - }).toBlob(function(blob) { - self.mediaCropped = true; - let data = new FormData(); - data.append('file', blob); - data.append('id', self.ids[self.carouselCursor]); - let url = '/api/compose/v0/media/update'; - axios.post(url, data).then(res => { - self.media[self.carouselCursor].url = res.data.url; - self.pageLoading = false; - self.page = 2; - }).catch(err => { - }); - }); + const { editState } = this.files[this.carouselCursor] + const croppedState = { + ...editState, + crop: this.$refs.cropper.getData() + } + this.editor.setEditState(this.carouselCursor, croppedState) + this.page = 2; break; case 2: @@ -1764,111 +1708,6 @@ export default { }); }, - applyFilterToMedia() { - // this is where the magic happens - let count = this.media.filter(m => m.filter_class).length; - if(count) { - this.page = 'filteringMedia'; - this.filteringRemainingCount = count; - this.$nextTick(() => { - this.isFilteringMedia = true; - Promise.all(this.media.map(media => { - return this.applyFilterToMediaSave(media); - })).catch(err => { - console.error(err); - swal('Oops!', 'An error occurred while applying filters to your media. Please refresh the page and try again. If the problem persist, please try a different web browser.', 'error'); - }); - }) - } else { - this.page = 3; - } - }, - - async applyFilterToMediaSave(media) { - if(!media.filter_class) { - return; - } - - // Load image - const image = document.createElement('img'); - image.src = media.url; - await new Promise((resolve, reject) => { - image.addEventListener('load', () => resolve()); - image.addEventListener('error', () => { - reject(new Error('Failed to load image')); - }); - }); - - // Create canvas - let canvas; - let usingOffscreenCanvas = false; - if('OffscreenCanvas' in window) { - canvas = new OffscreenCanvas(image.width, image.height); - usingOffscreenCanvas = true; - } else { - canvas = document.createElement('canvas'); - canvas.width = image.width; - canvas.height = image.height; - } - - // Draw image with filter to canvas - const ctx = canvas.getContext('2d'); - if (!ctx) { - throw new Error('Failed to get canvas context'); - } - if (!('filter' in ctx)) { - throw new Error('Canvas filter not supported'); - } - ctx.filter = App.util.filterCss[media.filter_class]; - ctx.drawImage(image, 0, 0, image.width, image.height); - ctx.save(); - - // Convert canvas to blob - let blob; - if(usingOffscreenCanvas) { - blob = await canvas.convertToBlob({ - type: media.mime, - quality: 1, - }); - } else { - blob = await new Promise((resolve, reject) => { - canvas.toBlob(blob => { - if(blob) { - resolve(blob); - } else { - reject( - new Error('Failed to convert canvas to blob'), - ); - } - }, media.mime, 1); - }); - } - - // Upload blob / Update media - const data = new FormData(); - data.append('file', blob); - data.append('id', media.id); - await axios.post('/api/compose/v0/media/update', data); - media.is_filtered = true; - this.updateFilteringMedia(); - }, - - updateFilteringMedia() { - this.filteringRemainingCount--; - this.filteringMediaTimeout = setTimeout(() => this.filteringMediaTimeoutJob(), 500); - }, - - filteringMediaTimeoutJob() { - if(this.filteringRemainingCount === 0) { - this.isFilteringMedia = false; - clearTimeout(this.filteringMediaTimeout); - setTimeout(() => this.compose(), 500); - } else { - clearTimeout(this.filteringMediaTimeout); - this.filteringMediaTimeout = setTimeout(() => this.filteringMediaTimeoutJob(), 1000); - } - }, - tagSearch(input) { if (input.length < 1) { return []; } let self = this; @@ -2059,6 +1898,11 @@ export default { this.collectionsCanLoadMore = true; }); } + }, + watch: { + files(value) { + this.editor.setSources(value.map(f => f.file)) + }, } } @@ -2111,5 +1955,34 @@ export default { } } } + .media-editor { + background-color: transparent; + border: none !important; + box-shadow: none !important; + font-size: 12px; + + --height-menu-row: 5rem; + --gap-preview: 0rem; + --height-menu-row-scroll: 10rem; + + --color-bg-button: transparent; /*var(--light);*/ + --color-bg-preview: transparent; /*var(--light-gray);*/ + --color-bg-button-hover: var(--light-gray); + --color-bg-acc: var(--card-bg); + + --color-fnt-default: var(--body-color); + --color-fnt-acc: var(--text-lighter); + + --color-scrollbar-thumb: var(--light-gray); + --color-scrollbar-both: var(--light-gray) transparent; + + --color-slider-thumb: var(--text-lighter); + --color-slider-progress: var(--light-gray); + --color-slider-track: var(--light); + + --color-crop-outline: var(--light-gray); + --color-crop-dashed: #ffffffde; + --color-crop-overlay: #00000082; + } } diff --git a/resources/assets/js/components/filters.js b/resources/assets/js/components/filters.js new file mode 100644 index 000000000..59579c809 --- /dev/null +++ b/resources/assets/js/components/filters.js @@ -0,0 +1,290 @@ +export const filterEffects = [ + { + name: '1984', + ops: [ + { type: 'sepia', intensity: 0.5 }, + { type: 'hue_rotate', angle: -30 }, + { type: 'adjust_color', brightness: 0, contrast: 0, saturation: 0.4 }, + ], + }, + { + name: 'Azen', + ops: [ + { type: 'sepia', intensity: 0.2 }, + { type: 'adjust_color', brightness: 0.15, contrast: 0, saturation: 0.4 }, + ], + }, + { + name: 'Astairo', + ops: [ + { type: 'sepia', intensity: 0.35 }, + { type: 'adjust_color', brightness: 0.2, contrast: 0.1, saturation: 0.3 }, + ], + }, + { + name: 'Grasbee', + ops: [ + { type: 'sepia', intensity: 0.5 }, + { type: 'adjust_color', brightness: 0, contrast: 0.2, saturation: 0.8 }, + ], + }, + { + name: 'Bookrun', + ops: [ + { type: 'sepia', intensity: 0.4 }, + { type: 'adjust_color', brightness: 0.1, contrast: 0.25, saturation: -0.1 }, + { type: 'hue_rotate', angle: -2 }, + ], + }, + { + name: 'Borough', + ops: [ + { type: 'sepia', intensity: 0.25 }, + { type: 'adjust_color', brightness: 0.25, contrast: 0.25, saturation: 0 }, + { type: 'hue_rotate', angle: 5 }, + ], + }, + { + name: 'Farms', + ops: [ + { type: 'sepia', intensity: 0.25 }, + { type: 'adjust_color', brightness: 0.25, contrast: 0.25, saturation: 0.35 }, + { type: 'hue_rotate', angle: -5 }, + ], + }, + { + name: 'Hairsadone', + ops: [ + { type: 'sepia', intensity: 0.15 }, + { type: 'adjust_color', brightness: 0.25, contrast: 0.25, saturation: 0 }, + { type: 'hue_rotate', angle: 5 }, + ], + }, + { + name: 'Cleana', + ops: [ + { type: 'sepia', intensity: 0.5 }, + { type: 'adjust_color', brightness: 0.25, contrast: 0.15, saturation: -0.1 }, + { type: 'hue_rotate', angle: -2 }, + ], + }, + { + name: 'Catpatch', + ops: [ + { type: 'sepia', intensity: 0.35 }, + { type: 'adjust_color', brightness: 0, contrast: .5, saturation: 0.1 }, + ], + }, + { + name: 'Earlyworm', + ops: [ + { type: 'sepia', intensity: 0.25 }, + { type: 'adjust_color', brightness: 0.25, contrast: 0.15, saturation: -0.1 }, + { type: 'hue_rotate', angle: -5 }, + ], + }, + { + name: 'Plaid', + ops: [{ type: 'adjust_color', brightness: 0.1, contrast: 0.1, saturation: 0 }], + }, + { + name: 'Kyo', + ops: [ + { type: 'sepia', intensity: 0.25 }, + { type: 'adjust_color', brightness: 0.2, contrast: 0.15, saturation: 0.35 }, + { type: 'hue_rotate', angle: -5 }, + ], + }, + { + name: 'Yefe', + ops: [ + { type: 'sepia', intensity: 0.4 }, + { type: 'adjust_color', brightness: 0.2, contrast: 0.5, saturation: 0.4 }, + { type: 'hue_rotate', angle: -10 }, + ], + }, + { + name: 'Godess', + ops: [ + { type: 'sepia', intensity: 0.5 }, + { type: 'adjust_color', brightness: 0.05, contrast: 0.05, saturation: 0.35 }, + ], + }, + { + name: 'Yards', + ops: [ + { type: 'sepia', intensity: 0.25 }, + { type: 'adjust_color', brightness: 0.2, contrast: 0.2, saturation: 0.05 }, + { type: 'hue_rotate', angle: -15 }, + ], + }, + { + name: 'Quill', + ops: [{ type: 'adjust_color', brightness: 0.25, contrast: -0.15, saturation: -1 }], + }, + { + name: 'Juno', + ops: [ + { type: 'sepia', intensity: 0.35 }, + { type: 'adjust_color', brightness: 0.15, contrast: 0.15, saturation: 0.8 }, + ], + }, + { + name: 'Rankine', + ops: [ + { type: 'sepia', intensity: 0.15 }, + { type: 'adjust_color', brightness: 0.1, contrast: 0.5, saturation: 0 }, + { type: 'hue_rotate', angle: -10 }, + ], + }, + { + name: 'Mark', + ops: [ + { type: 'sepia', intensity: 0.25 }, + { type: 'adjust_color', brightness: 0.3, contrast: 0.2, saturation: 0.25 }, + ], + }, + { + name: 'Chill', + ops: [{ type: 'adjust_color', brightness: 0, contrast: 0.5, saturation: 0.1 }], + }, + { + name: 'Van', + ops: [ + { type: 'sepia', intensity: 0.25 }, + { type: 'adjust_color', brightness: 0.05, contrast: 0.05, saturation: 1 }, + ], + }, + { + name: 'Apache', + ops: [ + { type: 'sepia', intensity: 0.35 }, + { type: 'adjust_color', brightness: 0.05, contrast: 0.05, saturation: 0.75 }, + ], + }, + { + name: 'May', + ops: [{ type: 'adjust_color', brightness: 0.15, contrast: 0.1, saturation: 0.1 }], + }, + { + name: 'Ceres', + ops: [ + { type: 'adjust_color', brightness: 0.4, contrast: -0.05, saturation: -1 }, + { type: 'sepia', intensity: 0.35 }, + ], + }, + { + name: 'Knoxville', + ops: [ + { type: 'sepia', intensity: 0.25 }, + { type: 'adjust_color', brightness: -0.1, contrast: 0.5, saturation: 0 }, + { type: 'hue_rotate', angle: -15 }, + ], + }, + { + name: 'Felicity', + ops: [{ type: 'adjust_color', brightness: 0.25, contrast: 0.1, saturation: 0.1 }], + }, + { + name: 'Sandblast', + ops: [ + { type: 'sepia', intensity: 0.15 }, + { type: 'adjust_color', brightness: 0.2, contrast: 0, saturation: 0 }, + ], + }, + { + name: 'Daisy', + ops: [ + { type: 'sepia', intensity: 0.75 }, + { type: 'adjust_color', brightness: 0.25, contrast: -0.25, saturation: 0.4 }, + ], + }, + { + name: 'Elevate', + ops: [ + { type: 'sepia', intensity: 0.25 }, + { type: 'adjust_color', brightness: 0.2, contrast: 0.25, saturation: -0.1 }, + ], + }, + { + name: 'Nevada', + ops: [ + { type: 'sepia', intensity: 0.25 }, + { type: 'adjust_color', brightness: -0.1, contrast: 0.5, saturation: 0 }, + { type: 'hue_rotate', angle: -15 }, + ], + }, + { + name: 'Futura', + ops: [ + { type: 'sepia', intensity: 0.15 }, + { type: 'adjust_color', brightness: 0.25, contrast: 0.25, saturation: 0.2 }, + ], + }, + { + name: 'Sleepy', + ops: [ + { type: 'sepia', intensity: 0.15 }, + { type: 'adjust_color', brightness: 0.25, contrast: 0.25, saturation: 0.2 }, + ], + }, + { + name: 'Steward', + ops: [ + { type: 'sepia', intensity: 0.35 }, + { type: 'adjust_color', brightness: 0.25, contrast: 0.1, saturation: 0.25 }, + ], + }, + { + name: 'Savoy', + ops: [ + { type: 'sepia', intensity: 0.4 }, + { type: 'adjust_color', brightness: 0.2, contrast: -0.1, saturation: 0.4 }, + { type: 'hue_rotate', angle: -10 }, + ], + }, + { + name: 'Blaze', + ops: [ + { type: 'sepia', intensity: 0.25 }, + { type: 'adjust_color', brightness: -0.05, contrast: 0.5, saturation: 0 }, + { type: 'hue_rotate', angle: -15 }, + ], + }, + { + name: 'Apricot', + ops: [ + { type: 'sepia', intensity: 0.25 }, + { type: 'adjust_color', brightness: 0.1, contrast: 0.1, saturation: 0 }, + ], + }, + { + name: 'Gloming', + ops: [ + { type: 'sepia', intensity: 0.35 }, + { type: 'adjust_color', brightness: 0.15, contrast: 0.2, saturation: 0.3 }, + ], + }, + { + name: 'Walter', + ops: [ + { type: 'sepia', intensity: 0.35 }, + { type: 'adjust_color', brightness: 0.25, contrast: -0.2, saturation: 0.4 }, + ], + }, + { + name: 'Poplar', + ops: [ + { type: 'adjust_color', brightness: 0.2, contrast: -0.15, saturation: -0.95 }, + { type: 'sepia', intensity: 0.5 }, + ], + }, + { + name: 'Xenon', + ops: [ + { type: 'sepia', intensity: 0.45 }, + { type: 'adjust_color', brightness: 0.75, contrast: 0.25, saturation: 0.3 }, + { type: 'hue_rotate', angle: -5 }, + ], + }, + ]