ソースを参照

Merge pull request #5374 from taye/feat-webgl-filters

Apply filters with WebGL
daniel 6 ヶ月 前
コミット
67434be414

+ 34 - 0
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": {
@@ -5841,6 +5842,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",
@@ -10163,6 +10170,15 @@
 				"b4a": "^1.6.4"
 			}
 		},
+		"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",
@@ -10240,6 +10256,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",
@@ -10727,6 +10749,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",

+ 1 - 0
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": {

+ 181 - 308
resources/assets/js/components/ComposeModal.vue

@@ -1,6 +1,6 @@
 <template>
 <div class="compose-modal-component">
-	<input type="file" id="pf-dz" name="media" class="w-100 h-100 d-none file-input" multiple="" v-bind:accept="config.uploader.media_types">
+	<input type="file" id="pf-dz" name="media" class="w-100 h-100 d-none file-input" multiple="" v-bind:accept="config.uploader.media_types" @input="onInputFile">
 	<canvas class="d-none" id="pr_canvas"></canvas>
 	<img class="d-none" id="pr_img">
 	<div class="timeline">
@@ -184,7 +184,6 @@
                             </template>
 							<a v-if="!pageLoading && page == 'addText'" class="font-weight-bold text-decoration-none" href="#" @click.prevent="composeTextPost()">Post</a>
                             <a v-if="!pageLoading && page == 'video-2'" class="font-weight-bold text-decoration-none" href="#" @click.prevent="compose()">Post</a>
-							<span v-if="!pageLoading && page == 'filteringMedia'" class="font-weight-bold text-decoration-none text-muted">Next</span>
 						</span>
 					</div>
 				</div>
@@ -342,8 +341,8 @@
 					</div>
 
 					<div v-else-if="page == 'cropPhoto'" class="w-100 h-100">
-						<div v-if="ids.length > 0">
-							<vue-cropper
+						<div v-if="media.length > 0">
+                            <vue-cropper
 								ref="cropper"
 								:relativeZoom="cropper.zoom"
 								:aspectRatio="cropper.aspectRatio"
@@ -358,37 +357,22 @@
 
 					<div v-else-if="page == 2" class="w-100 h-100">
 						<div v-if="media.length == 1">
-							<div slot="img" style="display:flex;min-height: 420px;align-items: center;">
-								<img :class="'d-block img-fluid w-100 ' + [media[carouselCursor].filter_class?media[carouselCursor].filter_class:'']" :src="media[carouselCursor].url" :alt="media[carouselCursor].description" :title="media[carouselCursor].description">
-							</div>
-							<hr>
-							<div v-if="ids.length > 0 && media[carouselCursor].type == 'image'" class="align-items-center px-2 pt-2">
-								<ul class="nav media-drawer-filters text-center">
-									<li class="nav-item">
-										<div class="p-1 pt-3">
-											<img :src="media[carouselCursor].url" width="100px" height="60px" v-on:click.prevent="toggleFilter($event, null)" class="cursor-pointer">
-										</div>
-										<a :class="[media[carouselCursor].filter_class == null ? 'nav-link text-primary active' : 'nav-link text-muted']" href="#" v-on:click.prevent="toggleFilter($event, null)">No Filter</a>
-									</li>
-									<li class="nav-item" v-for="(filter, index) in filters">
-										<div class="p-1 pt-3">
-                                            <div class="rounded" :class="filter[1]">
-											 <img :src="media[carouselCursor].url" width="100px" height="60px" v-on:click.prevent="toggleFilter($event, filter[1])">
-                                            </div>
-										</div>
-										<a :class="[media[carouselCursor].filter_class == filter[1] ? 'nav-link text-primary active' : 'nav-link text-muted']" href="#" v-on:click.prevent="toggleFilter($event, filter[1])">{{filter[0]}}</a>
-									</li>
-								</ul>
-							</div>
+                            <template v-if="media[0].type === 'image'">
+							    <media-editor-preview class="media-editor" :editor="editor" :sourceIndex="0" />
+							    <hr>
+                                <media-editor-filter-menu class="media-editor" :editor="editor" :sourceIndex="0" />
+                            </template>
+                            <img v-else class="d-block img-fluid w-100" src="/storage/no-preview.png" :alt="media[carouselCursor].description" :title="media[carouselCursor].description">
 						</div>
 						<div v-else-if="media.length > 1" class="d-flex-inline px-2 pt-2">
 							<ul class="nav media-drawer-filters text-center pb-3">
 								<li class="nav-item mx-md-4">&nbsp;</li>
-								<li v-for="(m, i) in media" :key="m.id + ':' + carouselCursor" class="nav-item mx-md-4">
+								<li v-for="(m, i) in media" :key="i + (ids[i] || m.url || '')" class="nav-item mx-md-4">
 										<div class="nav-link" style="display:block;width:300px;height:300px;" @click="carouselCursor = i">
 											<!-- <img :class="'d-block img-fluid w-100 ' + [m.filter_class?m.filter_class:'']" :src="m.url" :alt="m.description" :title="m.description"> -->
 											<div :class="[m.filter_class?m.filter_class:'']" style="width:100%;height:100%;display:block;">
-												<div :class="'rounded ' +  [i == carouselCursor ? ' border border-primary shadow':'']" :style="'display:block;width:100%;height:100%;background-image: url(' + m.url + ');background-size:cover;'"></div>
+												<media-editor-preview v-if="m.type === 'image'" class="media-editor" :editor="editor" :sourceIndex="i" :class="'rounded ' +  [i == carouselCursor ? ' border border-primary shadow':'']" style="width:100%;height:100%;" />
+                                                <img v-else class="d-block img-fluid w-100" src="/storage/no-preview.png" :alt="media[carouselCursor].description" :title="media[carouselCursor].description">
 											</div>
 										</div>
 										<div v-if="i == carouselCursor" class="text-center mb-0 small text-lighter font-weight-bold pt-2">
@@ -402,21 +386,8 @@
 								<li class="nav-item mx-md-4">&nbsp;</li>
 							</ul>
 							<hr>
-							<div v-if="ids.length > 0 && media[carouselCursor].type == 'image'" class="align-items-center px-2 pt-2">
-								<ul class="nav media-drawer-filters text-center">
-									<li class="nav-item">
-										<div class="p-1 pt-3">
-											<img :src="media[carouselCursor].url" width="100px" height="60px" v-on:click.prevent="toggleFilter($event, null)" class="cursor-pointer">
-										</div>
-										<a :class="[media[carouselCursor].filter_class == null ? 'nav-link text-primary active' : 'nav-link text-muted']" href="#" v-on:click.prevent="toggleFilter($event, null)">No Filter</a>
-									</li>
-									<li class="nav-item" v-for="(filter, index) in filters">
-										<div class="p-1 pt-3">
-											<img :src="media[carouselCursor].url" width="100px" height="60px" :class="filter[1]" v-on:click.prevent="toggleFilter($event, filter[1])">
-										</div>
-										<a :class="[media[carouselCursor].filter_class == filter[1] ? 'nav-link text-primary active' : 'nav-link text-muted']" href="#" v-on:click.prevent="toggleFilter($event, filter[1])">{{filter[0]}}</a>
-									</li>
-								</ul>
+							<div v-if="media[carouselCursor].type == 'image'" class="align-items-center px-2 pt-2">
+								<media-editor-filter-menu class="media-editor" :editor="editor" :sourceIndex="carouselCursor" />
 							</div>
 						</div>
 						<div v-else>
@@ -427,7 +398,7 @@
 					<div v-else-if="page == 3" class="w-100 h-100">
 						<div class="border-bottom mt-2">
 							<div class="media px-3">
-								<img :src="media[0].url" width="42px" height="42px" :class="[media[0].filter_class?'mr-2 ' + media[0].filter_class:'mr-2']">
+								<img :src="media[0].preview_url" width="42px" height="42px" class="mr-2">
 								<div class="media-body">
 									<div class="form-group">
 										<label class="font-weight-bold text-muted small d-none">Caption</label>
@@ -780,7 +751,7 @@
 					<div v-else-if="page == 'video-2'" class="w-100 h-100">
 						<div v-if="video.title.length" class="border-bottom">
 							<div class="media p-3">
-								<img :src="media[0].url" width="100px" height="70px" :class="[media[0].filter_class?'mr-2 ' + media[0].filter_class:'mr-2']">
+								<img :src="media[0].preview_url" width="100px" height="70px" class="mr-2">
 								<div class="media-body">
 									<p class="font-weight-bold mb-1">{{video.title ? video.title.slice(0,70) : 'Untitled'}}</p>
 									<p class="mb-0 text-muted small">{{video.description ? video.description.slice(0,90) : 'No description'}}</p>
@@ -839,13 +810,6 @@
 							</div>
 						</div>
 					</div>
-
-                    <div v-else-if="page == 'filteringMedia'" class="w-100 h-100 py-5">
-                        <div class="d-flex flex-column align-items-center justify-content-center py-5">
-                            <b-spinner small />
-                            <p class="font-weight-bold mt-3">Applying filters...</p>
-                        </div>
-                    </div>
 				</div>
 
 				<!-- card-footers -->
@@ -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;
 			});
-		},
 
-		toggleFilter(e, filter) {
-			this.media[this.carouselCursor].filter_class = filter;
-			this.currentFilter = filter;
+            await Promise.all(uploadPromises).finally(() => {
+				this.uploadProgress = 0;
+			    this.uploading = false;
+            });
 		},
 
-		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;
-
-            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;
-            })
-        },
+            const prevIndex = this.carouselCursor
+            const newIndex = prevIndex + (dir === 'prev' ? -1 : 1)
 
-        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;
-            }
+            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
         },
 
-		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))
+		},
 	}
 }
 </script>
@@ -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;
+        }
 	}
 </style>

+ 290 - 0
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 },
+      ],
+    },
+  ]