feat: apply filters with WebGL

This commit is contained in:
Taye Adeyemi 2024-12-03 15:16:56 +01:00
parent 8cd01b2ede
commit 669a68d27b
4 changed files with 507 additions and 309 deletions

34
package-lock.json generated
View file

@ -44,6 +44,7 @@
"vue-loading-overlay": "^3.3.3", "vue-loading-overlay": "^3.3.3",
"vue-timeago": "^5.1.2", "vue-timeago": "^5.1.2",
"vue-tribute": "^1.0.7", "vue-tribute": "^1.0.7",
"webgl-media-editor": "^0.0.1",
"zuck.js": "^1.6.0" "zuck.js": "^1.6.0"
}, },
"devDependencies": { "devDependencies": {
@ -5775,6 +5776,12 @@
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" "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": { "node_modules/glob": {
"version": "7.2.3", "version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "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", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.1.tgz",
"integrity": "sha512-x9v3H/lTKIJKQQe7RPQkLfKAnc9lUTkWDypIQgTzPJAq+5/GCDHonmshfvlsNSj58yyshbIJJDLmU15qNERrXQ==" "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": { "node_modules/thunky": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
@ -10048,6 +10064,12 @@
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
"dev": true "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": { "node_modules/twitter-text": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/twitter-text/-/twitter-text-2.0.5.tgz", "resolved": "https://registry.npmjs.org/twitter-text/-/twitter-text-2.0.5.tgz",
@ -10535,6 +10557,18 @@
"node": ">= 8" "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": { "node_modules/webidl-conversions": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",

View file

@ -71,6 +71,7 @@
"vue-loading-overlay": "^3.3.3", "vue-loading-overlay": "^3.3.3",
"vue-timeago": "^5.1.2", "vue-timeago": "^5.1.2",
"vue-tribute": "^1.0.7", "vue-tribute": "^1.0.7",
"webgl-media-editor": "^0.0.1",
"zuck.js": "^1.6.0" "zuck.js": "^1.6.0"
}, },
"collective": { "collective": {

View file

@ -1,6 +1,6 @@
<template> <template>
<div class="compose-modal-component"> <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> <canvas class="d-none" id="pr_canvas"></canvas>
<img class="d-none" id="pr_img"> <img class="d-none" id="pr_img">
<div class="timeline"> <div class="timeline">
@ -184,7 +184,6 @@
</template> </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 == '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> <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> </span>
</div> </div>
</div> </div>
@ -342,8 +341,8 @@
</div> </div>
<div v-else-if="page == 'cropPhoto'" class="w-100 h-100"> <div v-else-if="page == 'cropPhoto'" class="w-100 h-100">
<div v-if="ids.length > 0"> <div v-if="media.length > 0">
<vue-cropper <vue-cropper
ref="cropper" ref="cropper"
:relativeZoom="cropper.zoom" :relativeZoom="cropper.zoom"
:aspectRatio="cropper.aspectRatio" :aspectRatio="cropper.aspectRatio"
@ -358,37 +357,22 @@
<div v-else-if="page == 2" class="w-100 h-100"> <div v-else-if="page == 2" class="w-100 h-100">
<div v-if="media.length == 1"> <div v-if="media.length == 1">
<div slot="img" style="display:flex;min-height: 420px;align-items: center;"> <template v-if="media[0].type === 'image'">
<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"> <media-editor-preview class="media-editor" :editor="editor" :sourceIndex="0" />
</div> <hr>
<hr> <media-editor-filter-menu class="media-editor" :editor="editor" :sourceIndex="0" />
<div v-if="ids.length > 0 && media[carouselCursor].type == 'image'" class="align-items-center px-2 pt-2"> </template>
<ul class="nav media-drawer-filters text-center"> <img v-else class="d-block img-fluid w-100" src="/storage/no-preview.png" :alt="media[carouselCursor].description" :title="media[carouselCursor].description">
<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>
</div> </div>
<div v-else-if="media.length > 1" class="d-flex-inline px-2 pt-2"> <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"> <ul class="nav media-drawer-filters text-center pb-3">
<li class="nav-item mx-md-4">&nbsp;</li> <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"> <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"> --> <!-- <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="[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> </div>
<div v-if="i == carouselCursor" class="text-center mb-0 small text-lighter font-weight-bold pt-2"> <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> <li class="nav-item mx-md-4">&nbsp;</li>
</ul> </ul>
<hr> <hr>
<div v-if="ids.length > 0 && media[carouselCursor].type == 'image'" class="align-items-center px-2 pt-2"> <div v-if="media[carouselCursor].type == 'image'" class="align-items-center px-2 pt-2">
<ul class="nav media-drawer-filters text-center"> <media-editor-filter-menu class="media-editor" :editor="editor" :sourceIndex="carouselCursor" />
<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> </div>
</div> </div>
<div v-else> <div v-else>
@ -427,7 +398,7 @@
<div v-else-if="page == 3" class="w-100 h-100"> <div v-else-if="page == 3" class="w-100 h-100">
<div class="border-bottom mt-2"> <div class="border-bottom mt-2">
<div class="media px-3"> <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="media-body">
<div class="form-group"> <div class="form-group">
<label class="font-weight-bold text-muted small d-none">Caption</label> <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-else-if="page == 'video-2'" class="w-100 h-100">
<div v-if="video.title.length" class="border-bottom"> <div v-if="video.title.length" class="border-bottom">
<div class="media p-3"> <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"> <div class="media-body">
<p class="font-weight-bold mb-1">{{video.title ? video.title.slice(0,70) : 'Untitled'}}</p> <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> <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>
</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> </div>
<!-- card-footers --> <!-- card-footers -->
@ -875,13 +839,17 @@ import 'cropperjs/dist/cropper.css';
import Autocomplete from '@trevoreyre/autocomplete-vue' import Autocomplete from '@trevoreyre/autocomplete-vue'
import '@trevoreyre/autocomplete-vue/dist/style.css' import '@trevoreyre/autocomplete-vue/dist/style.css'
import VueTribute from 'vue-tribute' import VueTribute from 'vue-tribute'
import { MediaEditor, MediaEditorPreview, MediaEditorFilterMenu } from 'webgl-media-editor/vue2'
import { filterEffects } from './filters';
export default { export default {
components: { components: {
VueCropper, VueCropper,
Autocomplete, Autocomplete,
VueTribute VueTribute,
MediaEditorPreview,
MediaEditorFilterMenu
}, },
data() { data() {
@ -892,10 +860,9 @@ export default {
composeText: '', composeText: '',
composeTextLength: 0, composeTextLength: 0,
nsfw: false, nsfw: false,
filters: [],
currentFilter: false,
ids: [], ids: [],
media: [], media: [],
files: [],
carouselCursor: 0, carouselCursor: 0,
uploading: false, uploading: false,
uploadProgress: 100, uploadProgress: 100,
@ -923,7 +890,6 @@ export default {
}, },
namedPages: [ namedPages: [
'filteringMedia',
'cropPhoto', 'cropPhoto',
'tagPeople', 'tagPeople',
'addLocation', 'addLocation',
@ -1044,13 +1010,26 @@ export default {
collectionsPage: 1, collectionsPage: 1,
collectionsCanLoadMore: false, collectionsCanLoadMore: false,
spoilerText: undefined, spoilerText: undefined,
isFilteringMedia: false,
filteringMediaTimeout: undefined,
filteringRemainingCount: 0,
isPosting: false, 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: { computed: {
spoilerTextLength: function() { spoilerTextLength: function() {
return this.spoilerText ? this.spoilerText.length : 0; return this.spoilerText ? this.spoilerText.length : 0;
@ -1058,7 +1037,6 @@ export default {
}, },
beforeMount() { beforeMount() {
this.filters = window.App.util.filters.sort();
axios.get('/api/compose/v0/settings') axios.get('/api/compose/v0/settings')
.then(res => { .then(res => {
this.composeSettings = res.data; this.composeSettings = res.data;
@ -1075,8 +1053,12 @@ export default {
}); });
}, },
mounted() { destroyed() {
this.mediaWatcher(); this.files.forEach(fileInfo => {
URL.revokeObjectURL(fileInfo.url);
})
this.files.length = this.media.length = 0
this.editor = undefined
}, },
methods: { methods: {
@ -1156,39 +1138,55 @@ export default {
this.mode = 'text'; this.mode = 'text';
}, },
mediaWatcher() { onInputFile(event) {
let self = this; const input = event.target
$(document).on('change', '#pf-dz', function(e) { const files = Array.from(input.files)
self.mediaUpload(); input.value = null;
});
},
mediaUpload() {
let self = this; let self = this;
self.uploading = true;
let io = document.querySelector('#pf-dz'); files.forEach((file, i) => {
if(!io.files.length) { if(self.media && self.media.length >= self.config.uploader.album_limit) {
self.uploading = false;
}
Array.prototype.forEach.call(io.files, function(io, i) {
if(self.media && self.media.length + i >= self.config.uploader.album_limit) {
swal('Error', 'You can only upload ' + self.config.uploader.album_limit + ' photos per album', 'error'); swal('Error', 'You can only upload ' + self.config.uploader.album_limit + ' photos per album', 'error');
self.uploading = false;
self.page = 2; self.page = 2;
return; return;
} }
let type = io.type;
let acceptedMimes = self.config.uploader.media_types.split(','); let acceptedMimes = self.config.uploader.media_types.split(',');
let validated = $.inArray(type, acceptedMimes); let validated = $.inArray(file.type, acceptedMimes);
if(validated == -1) { 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'); 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; self.page = 2;
return; 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(); let form = new FormData();
form.append('file', io); form.append('file', file);
let xhrConfig = { let xhrConfig = {
onUploadProgress: function(e) { 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) { .then(function(e) {
self.uploadProgress = 100; self.uploadProgress = 100;
self.ids.push(e.data.id); self.ids.push(e.data.id);
self.media.push(e.data); Object.assign(media, e.data)
self.uploading = false;
setTimeout(function() { setTimeout(function() {
// if(type === 'video/mp4') { // if(type === 'video/mp4') {
// self.pageTitle = 'Edit Video Details'; // self.pageTitle = 'Edit Video Details';
@ -1216,131 +1215,100 @@ export default {
}).catch(function(e) { }).catch(function(e) {
switch(e.response.status) { switch(e.response.status) {
case 403: case 403:
self.uploading = false;
io.value = null;
swal('Account size limit reached', 'Contact your admin for assistance.', 'error'); swal('Account size limit reached', 'Contact your admin for assistance.', 'error');
self.page = 2; self.page = 2;
break; break;
case 413: case 413:
self.uploading = false; 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');
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');
self.page = 2; self.page = 2;
break; break;
case 451: case 451:
self.uploading = false;
io.value = null;
swal('Banned Content', 'This content has been banned and cannot be uploaded.', 'error'); swal('Banned Content', 'This content has been banned and cannot be uploaded.', 'error');
self.page = 2; self.page = 2;
break; break;
case 429: 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'); 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; self.page = 2;
break; break;
case 500: case 500:
self.uploading = false;
io.value = null;
swal('Error', e.response.data.message, 'error'); swal('Error', e.response.data.message, 'error');
self.page = 2; self.page = 2;
break; break;
default: default:
self.uploading = false;
io.value = null;
swal('Oops, something went wrong!', 'An unexpected error occurred.', 'error'); swal('Oops, something went wrong!', 'An unexpected error occurred.', 'error');
self.page = 2; self.page = 2;
break; break;
} }
throw e
}); });
io.value = null;
self.uploadProgress = 0;
}); });
await Promise.all(uploadPromises).finally(() => {
this.uploadProgress = 0;
this.uploading = false;
});
}, },
toggleFilter(e, filter) { async deleteMedia() {
this.media[this.carouselCursor].filter_class = filter;
this.currentFilter = filter;
},
deleteMedia() {
if(window.confirm('Are you sure you want to delete this media?') == false) { if(window.confirm('Are you sure you want to delete this media?') == false) {
return; return;
} }
let id = this.media[this.carouselCursor].id; let id = this.media[this.carouselCursor].id;
axios.delete('/api/compose/v0/media/delete', { if (id) {
params: { try {
id: id await axios.delete('/api/compose/v0/media/delete', {
} params: {
}).then(res => { id: id
this.ids.splice(this.carouselCursor, 1); }
this.media.splice(this.carouselCursor, 1); })
if(this.media.length == 0) { }
this.ids = []; catch(err) {
this.media = []; swal('Whoops!', 'An error occured when attempting to delete this, please try again', 'error');
this.carouselCursor = 0; return
} else { }
this.carouselCursor = 0; }
} this.ids.splice(this.carouselCursor, 1);
}).catch(err => { this.media.splice(this.carouselCursor, 1);
swal('Whoops!', 'An error occured when attempting to delete this, please try again', 'error');
}); 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) { mediaReorder(dir) {
const m = this.media; const prevIndex = this.carouselCursor
const cur = this.carouselCursor; const newIndex = prevIndex + (dir === 'prev' ? -1 : 1)
const pla = m[cur];
let res = [];
let cursor = 0;
if(dir == 'prev') { if (newIndex < 0 || newIndex >= this.media.length) return
if(cur == 0) {
for (let i = cursor; i < m.length - 1; i++) { const [removedFile] = this.files.splice(prevIndex, 1)
res[i] = m[i+1]; const [removedMedia] = this.media.splice(prevIndex, 1)
} const [removedId] = this.ids.splice(prevIndex, 1)
res[m.length - 1] = pla;
cursor = 0; this.files.splice(newIndex, 0, removedFile)
} else { this.media.splice(newIndex, 0, removedMedia)
res = this.handleSwap(m, cur, cur - 1); this.ids.splice(newIndex, 0, removedId)
cursor = cur - 1; this.carouselCursor = newIndex
}
} 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;
})
}, },
handleSwap(arr, index1, index2) { async compose() {
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() {
let state = this.composeState; let state = this.composeState;
if(this.uploadProgress != 100 || this.ids.length == 0) { if(this.files.length == 0) {
return; return;
} }
@ -1353,11 +1321,14 @@ export default {
switch(state) { switch(state) {
case 'publish': case 'publish':
this.isPosting = true; this.isPosting = true;
let count = this.media.filter(m => m.filter_class && !m.hasOwnProperty('is_filtered')).length;
if(count) { try {
this.applyFilterToMedia(); await this.mediaUpload().finally(() => this.isPosting = false)
return; } catch {
this.isPosting = false;
return
} }
if(this.composeSettings.media_descriptions === true) { if(this.composeSettings.media_descriptions === true) {
let count = this.media.filter(m => { let count = this.media.filter(m => {
return !m.hasOwnProperty('alt') || m.alt.length < 2; return !m.hasOwnProperty('alt') || m.alt.length < 2;
@ -1420,6 +1391,8 @@ export default {
this.defineErrorMessage(err); this.defineErrorMessage(err);
break; break;
} }
}).finally(() => {
this.isPosting = false;
}); });
return; return;
break; break;
@ -1488,10 +1461,6 @@ export default {
switch(this.mode) { switch(this.mode) {
case 'photo': case 'photo':
switch(this.page) { switch(this.page) {
case 'filteringMedia':
this.page = 2;
break;
case 'addText': case 'addText':
this.page = 1; this.page = 1;
break; break;
@ -1526,10 +1495,6 @@ export default {
case 'video': case 'video':
switch(this.page) { switch(this.page) {
case 'filteringMedia':
this.page = 2;
break;
case 'licensePicker': case 'licensePicker':
this.page = 'video-2'; this.page = 'video-2';
break; break;
@ -1550,10 +1515,6 @@ export default {
this.page = 1; this.page = 1;
break; break;
case 'filteringMedia':
this.page = 2;
break;
case 'textOptions': case 'textOptions':
this.page = 'addText'; this.page = 'addText';
break; break;
@ -1593,31 +1554,14 @@ export default {
this.page = 2; this.page = 2;
break; break;
case 'filteringMedia':
break;
case 'cropPhoto': case 'cropPhoto':
this.pageLoading = true; const { editState } = this.files[this.carouselCursor]
let self = this; const croppedState = {
this.$refs.cropper.getCroppedCanvas({ ...editState,
maxWidth: 4096, crop: this.$refs.cropper.getData()
maxHeight: 4096, }
fillColor: '#fff', this.editor.setEditState(this.carouselCursor, croppedState)
imageSmoothingEnabled: false, this.page = 2;
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 => {
});
});
break; break;
case 2: 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) { tagSearch(input) {
if (input.length < 1) { return []; } if (input.length < 1) { return []; }
let self = this; let self = this;
@ -2059,6 +1898,11 @@ export default {
this.collectionsCanLoadMore = true; this.collectionsCanLoadMore = true;
}); });
} }
},
watch: {
files(value) {
this.editor.setSources(value.map(f => f.file))
},
} }
} }
</script> </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> </style>

View file

@ -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 },
],
},
]