diff options
| author | Jules Laplace <julescarbon@gmail.com> | 2020-07-16 18:25:18 +0200 |
|---|---|---|
| committer | Jules Laplace <julescarbon@gmail.com> | 2020-07-16 18:25:18 +0200 |
| commit | e2e27ed91b8ed8a024223ad03be9d2566750e880 (patch) | |
| tree | 24ab0b65ce5ca7987bdaf8a7d99d3d942bc1f19e | |
| parent | c4d20db0c6a8a0ba45a453ad1b5a3296db7a127e (diff) | |
cropping images and uploading multiple versions
| -rw-r--r-- | animism-align/cli/app/controllers/upload_controller.py | 11 | ||||
| -rw-r--r-- | animism-align/cli/app/sql/models/upload.py | 2 | ||||
| -rw-r--r-- | animism-align/cli/app/utils/file_utils.py | 2 | ||||
| -rw-r--r-- | animism-align/frontend/api/crud.reducer.js | 4 | ||||
| -rw-r--r-- | animism-align/frontend/api/crud.upload.js | 10 | ||||
| -rw-r--r-- | animism-align/frontend/common/upload.helpers.js | 28 | ||||
| -rw-r--r-- | animism-align/frontend/util/index.js | 82 | ||||
| -rw-r--r-- | animism-align/frontend/views/media/components/media.form.js | 5 | ||||
| -rw-r--r-- | animism-align/frontend/views/media/components/media.formImage.js | 140 | ||||
| -rw-r--r-- | animism-align/frontend/views/media/media.css | 3 | ||||
| -rw-r--r-- | animism-align/frontend/views/paragraph/components/paragraph.types.js | 1 |
11 files changed, 190 insertions, 98 deletions
diff --git a/animism-align/cli/app/controllers/upload_controller.py b/animism-align/cli/app/controllers/upload_controller.py index 5fec077..1a324cb 100644 --- a/animism-align/cli/app/controllers/upload_controller.py +++ b/animism-align/cli/app/controllers/upload_controller.py @@ -53,6 +53,11 @@ class UploadView(FlaskView): except: raise APIError('No username specified') + try: + tag = request.form.get('tag') + except: + raise APIError('No tag specified') + param_name = 'image' if param_name not in request.files: raise APIError('No file uploaded') @@ -93,11 +98,10 @@ class UploadView(FlaskView): session.close() return jsonify(response) - tag = request.form.get('tag') uploaded_im_fn = secure_filename(file.filename) uploaded_im_abspath = os.path.join(app_cfg.DIR_UPLOADS, tag) uploaded_im_fullpath = os.path.join(uploaded_im_abspath, uploaded_im_fn) - uploaded_im_stored_fn = os.path.join(tag, uploaded_im_fn) + uploaded_im_stored_fn = uploaded_im_fn os.makedirs(uploaded_im_abspath, exist_ok=True) # nparr.tofile(uploaded_im_fullpath) @@ -128,9 +132,10 @@ class UploadView(FlaskView): sha256 = upload.sha256 fn = upload.fn + tag = upload.tag # uploaded_im_fn = secure_filename(fn) - uploaded_im_abspath = os.path.join(app_cfg.DIR_UPLOADS, fn) + uploaded_im_abspath = os.path.join(app_cfg.DIR_UPLOADS, tag, fn) # uploaded_im_fullpath = os.path.join(uploaded_im_abspath, fn) if os.path.exists(uploaded_im_abspath): print("Removing " + uploaded_im_abspath) diff --git a/animism-align/cli/app/sql/models/upload.py b/animism-align/cli/app/sql/models/upload.py index 462c236..ae30a54 100644 --- a/animism-align/cli/app/sql/models/upload.py +++ b/animism-align/cli/app/sql/models/upload.py @@ -43,5 +43,7 @@ class Upload(Base): # return join(self.filepath(), self.filename()) def url(self): + if self.tag: + return join('/static/data_store/uploads', self.tag, self.fn) return join('/static/data_store/uploads', self.fn) # return join(app_cfg.URL_UPLOADS, sha256_tree(self.sha256), self.filename()) diff --git a/animism-align/cli/app/utils/file_utils.py b/animism-align/cli/app/utils/file_utils.py index 7f1f417..1d19fd6 100644 --- a/animism-align/cli/app/utils/file_utils.py +++ b/animism-align/cli/app/utils/file_utils.py @@ -39,7 +39,7 @@ from tqdm import tqdm ZERO_PADDING = 6 # padding for enumerated image filenames HASH_TREE_DEPTH = 2 HASH_BRANCH_SIZE = 2 -VALID_IMAGE_EXTS = ['jpg', 'jpeg', 'png'] +VALID_IMAGE_EXTS = ['gif', 'jpg', 'jpeg', 'png'] VALID_VIDEO_EXTS = ['mp4', 'mov'] # ------------------------------------------ diff --git a/animism-align/frontend/api/crud.reducer.js b/animism-align/frontend/api/crud.reducer.js index 38aa4f8..ece6cec 100644 --- a/animism-align/frontend/api/crud.reducer.js +++ b/animism-align/frontend/api/crud.reducer.js @@ -115,6 +115,10 @@ export const crudReducer = (type) => { ...state, update: action.data, index: addToIndex(state.index, action.data.res, state.options.sort), + show: state.show.res.id === action.data.res.id ? { + ...state.show.res, + ...action.data.res, + } : state.show.res } case crud_type.index_error: return { diff --git a/animism-align/frontend/api/crud.upload.js b/animism-align/frontend/api/crud.upload.js index 8a711c7..beec86c 100644 --- a/animism-align/frontend/api/crud.upload.js +++ b/animism-align/frontend/api/crud.upload.js @@ -6,9 +6,17 @@ export function crud_upload(type, data, dispatch) { const { id } = data const fd = new FormData() + if (!data.tag) { + data.tag = 'misc' + } Object.keys(data).forEach(key => { - if (key !== 'id') { + if (key.indexOf('__') !== -1) return + if (key === 'id') return + const fn_key = '__' + key + '_filename' + if (fn_key in data) { + fd.append(key, data[key], data[fn_key]) + } else { fd.append(key, data[key]) } }) diff --git a/animism-align/frontend/common/upload.helpers.js b/animism-align/frontend/common/upload.helpers.js index f26e2cc..60d5b82 100644 --- a/animism-align/frontend/common/upload.helpers.js +++ b/animism-align/frontend/common/upload.helpers.js @@ -107,6 +107,23 @@ function getScale(width, height, viewportWidth, viewportHeight, fillViewport) { return 1 } +function getImageProperties(img) { + // img is an image + if ('naturalWidth' in img) { + const { naturalWidth, naturalHeight } = img + const jpeg = !!img.src.match(/data:image\/jpeg|\.jpeg$|\.jpg$/i) + const hasDataURI = !!img.src.match(/^data:/) + return { naturalWidth, naturalHeight, jpeg, hasDataURI } + } + // img is a canvas + return { + naturalWidth: img.width, + naturalHeight: img.height, + jpeg: false, + hasDataURI: false, + } +} + export function renderToCanvas(img, options) { if (!img) return null options = options || {} @@ -126,7 +143,7 @@ export function renderToCanvas(img, options) { canvas.width = Math.round(img.naturalWidth * scale) canvas.height = Math.round(img.naturalHeight * scale) */ - const { naturalWidth, naturalHeight } = img + const { naturalWidth, naturalHeight, jpeg, hasDataURI } = getImageProperties(img) if (maxSide > 0) { if (naturalWidth > naturalHeight) { canvas.width = Math.min(maxSide, naturalWidth) @@ -140,8 +157,6 @@ export function renderToCanvas(img, options) { canvas.height = naturalHeight } const { correctOrientation } = options - const jpeg = !!img.src.match(/data:image\/jpeg|\.jpeg$|\.jpg$/i) - const hasDataURI = !!img.src.match(/^data:/) ctx.save() @@ -161,8 +176,11 @@ export function renderToCanvas(img, options) { return canvas } -export function renderThumbnail(img) { - const resized = renderToCanvas(img, { correctOrientation: true }) +export function renderThumbnail(img, options) { + const resized = renderToCanvas(img, { + correctOrientation: true, + ...options, + }) // const canvas = document.createElement('canvas') // document.querySelector('#user_photo_canvas') // const ctx = canvas.getContext('2d') // ctx.fillStyle = 'black' diff --git a/animism-align/frontend/util/index.js b/animism-align/frontend/util/index.js index afebe13..37369f0 100644 --- a/animism-align/frontend/util/index.js +++ b/animism-align/frontend/util/index.js @@ -90,20 +90,11 @@ export const sha256_tree = (sha256, branch_size=2, tree_depth=2) => { return tree } -export const imageUrl = (sha256, frame, size = 'th') => [ - 'https://' + process.env.S3_HOST + '/v1/media/keyframes', - sha256_tree(sha256), - pad(frame, 6), - size, - 'index.jpg' -].filter(s => !!s).join('/') - -export const uploadUri = ({ sha256, ext }) => '/static/data/uploads' + sha256_tree(sha256) + '/' + sha256 + ext -export const metadataUri = (sha256, tag) => '/metadata/' + sha256 + '/' + tag + '/' -export const keyframeUri = (sha256, frame) => '/metadata/' + sha256 + '/keyframe/' + pad(frame, 6) + '/' - -export const preloadImage = url => ( +export const preloadImage = (url, anonymous=false) => ( new Promise((resolve, reject) => { + if (typeof url === 'object' && url instanceof Image) { + return resolve(url) + } const image = new Image() let loaded = false image.onload = () => { @@ -120,7 +111,9 @@ export const preloadImage = url => ( resolve(image) } // console.log(img.src) - // image.crossOrigin = 'anonymous' + if (anonymous) { + image.crossOrigin = 'anonymous' + } image.src = url if (image.complete) { image.onload() @@ -130,57 +123,50 @@ export const preloadImage = url => ( export const cropImage = (url, crop, maxSide) => { return new Promise((resolve, reject) => { - let { x, y, w, h } = crop - const image = new Image() - let loaded = false - x = parseFloat(x) - y = parseFloat(y) - w = parseFloat(w) - h = parseFloat(h) - image.onload = () => { - if (loaded) return - loaded = true - image.onload = null + preloadImage(url, true) + .then(image => { + let { x, y, w, h } = crop + x = parseFloat(x) + y = parseFloat(y) + w = parseFloat(w) + h = parseFloat(h) const canvas = document.createElement('canvas') const ctx = canvas.getContext('2d') const { naturalWidth, naturalHeight } = image - let height, width + + let width, height + let cropWidth = naturalWidth * w + let cropHeight = naturalHeight * h if (maxSide > 0) { - if (naturalWidth > naturalHeight) { - width = Math.min(maxSide, naturalWidth) - height = naturalHeight * canvas.width / naturalWidth + if (cropWidth > cropHeight) { + width = Math.min(maxSide, cropWidth) + height = cropHeight * width / cropWidth } else { - height = Math.min(maxSide, naturalHeight) - width = naturalWidth * canvas.height / naturalHeight + height = Math.min(maxSide, cropHeight) + width = cropWidth * height / cropHeight } } else { - width = naturalWidth - height = naturalHeight + width = cropWidth + height = cropHeight } canvas.width = w * width canvas.height = h * height + ctx.drawImage( image, - Math.round(x * width), - Math.round(y * height), - Math.round(w * width), - Math.round(h * height), + Math.round(x * naturalWidth), + Math.round(y * naturalHeight), + Math.round(w * naturalWidth), + Math.round(h * naturalHeight), 0, 0, canvas.width, canvas.height ) + // console.log(x, y, w, h) + // console.log(naturalWidth, naturalHeight) + // console.log(width, height) resolve(canvas) - } - image.onerror = () => { - console.log('image error') - reject() - } - // console.log(img.src) - image.crossOrigin = 'anonymous' - image.src = url - if (image.complete) { - image.onload() - } + }) }) } export const urlSearchParamsToDict = search => { diff --git a/animism-align/frontend/views/media/components/media.form.js b/animism-align/frontend/views/media/components/media.form.js index c82b384..9b93788 100644 --- a/animism-align/frontend/views/media/components/media.form.js +++ b/animism-align/frontend/views/media/components/media.form.js @@ -79,12 +79,15 @@ export default class MediaForm extends Component { } handleSettingsChange(name, value) { + if (name !== 'multiple') { + value = { [name]: value } + } this.setState({ data: { ...this.state.data, settings: { ...this.state.data.settings, - [name]: value, + ...value, } } }) diff --git a/animism-align/frontend/views/media/components/media.formImage.js b/animism-align/frontend/views/media/components/media.formImage.js index d86a6d8..23fd7e7 100644 --- a/animism-align/frontend/views/media/components/media.formImage.js +++ b/animism-align/frontend/views/media/components/media.formImage.js @@ -2,14 +2,22 @@ import React, { Component } from 'react' import { Link } from 'react-router-dom' import { session } from '../../../session' -import { capitalize } from '../../../util' +import actions from '../../../actions' +import { capitalize, preloadImage, cropImage } from '../../../util' -import { TextInput, LabelDescription, FileInputField, Select, TextArea, Checkbox, SubmitButton, Loader } from '../../../common' +import { TextInput, LabelDescription, UploadImage, Select, TextArea, Checkbox, SubmitButton, Loader } from '../../../common' +import { renderThumbnail } from '../../../common/upload.helpers' import ImageSelection from './media.formImageSelection' +const DISPLAY_SIZE = 1024 +const DISPLAY_QUALITY= 80 +const THUMBNAIL_SIZE = 320 +const THUMBNAIL_QUALITY = 80 + export default class MediaImageForm extends Component { state = { + img: null, } constructor(props) { @@ -18,6 +26,17 @@ export default class MediaImageForm extends Component { this.handleChange = this.handleChange.bind(this) this.handleSettingsChange = this.handleSettingsChange.bind(this) this.handleUpload = this.handleUpload.bind(this) + this.handleCrop = this.handleCrop.bind(this) + this.replaceTaggedSize = this.replaceTaggedSize.bind(this) + this.uploadTaggedSize = this.uploadTaggedSize.bind(this) + } + + componentDidMount() { + // this.setState({ }) + if (this.props.data.settings.fullsize) { + preloadImage(this.props.data.settings.fullsize.url) + .then(img => this.setState({ img })) + } } handleChange(e) { @@ -33,64 +52,109 @@ export default class MediaImageForm extends Component { this.props.onSettingsChange(name, value) } - handleUpload(image) { - // upload fullsize - this.uploadFullSize(image) - .then(res => { - this.props.onSettingsChange('fullsize', data.res) - setTimeout(() => { - }) + handleUpload({ file, img, canvas, blob }) { + // sizes: fullsize, display, thumbnail + this.replaceTaggedSize(file, 'fullsize') + .then(data => { + this.setState({ img }) + this.props.onSettingsChange('multiple', { + fullsize: data, + crop: {}, + }) + return this.replaceTaggedSize(blob, 'display', file.name) + }).then(data => { + this.props.onSettingsChange('multiple', { + display: data, + }) + this.uploadThumbnail(img) }) } - uploadFullSize(image) { - actions.upload.upload({ - image, - tag: 'fullsize', - username: 'animism', - }).then(data => { - console.log(data.res) - return data.res - }) + uploadThumbnail(img) { + const { fn } = this.props.data.settings.fullsize + const thumbnailCanvas = renderThumbnail(img, { maxSide: THUMBNAIL_SIZE }) + thumbnailCanvas.toBlob(thumbnail => { + this.replaceTaggedSize(thumbnail, 'thumbnail', fn).then(data => { + this.props.onSettingsChange('multiple', { + thumbnail: data, + }) + }) + }, 'image/jpeg', THUMBNAIL_QUALITY) } - - uploadThumbnail(image) { - actions.upload.upload({ - image, - tag: 'thumbnail', - username: 'animism', - }).then(data => { - console.log(data.res) - }) + + replaceTaggedSize(image, tag, fn) { + // when we upload an image, if the image already exists in this "position" + // on the record, we should also delete it + if (this.props.data.settings[tag] && this.props.data.settings[tag].id) { + return actions.upload.destroy(this.props.data.settings[tag]) + .then(() => { + return this.uploadTaggedSize(image, tag, fn) + }) + } + return this.uploadTaggedSize(image, tag, fn) } - uploadCrop(image) { - actions.upload.upload({ + uploadTaggedSize(image, tag, fn) { + const uploadData = { image, - tag: 'crop', + tag, username: 'animism', - }).then(data => { - console.log(data.res) - this.props.onSelect('url', data.res.url) + } + if (fn) { + uploadData['__image_filename'] = fn + } + // console.log(uploadData) + return actions.upload.upload(uploadData).then(data => { + // console.log(data) + return data.res }) } + handleCrop(crop) { + // when cropping an image, re-upload the display image and thumbnail + // console.log(crop) + cropImage(this.state.img, crop, DISPLAY_SIZE) + .then(canvas => { + canvas.toBlob(blob => { + // console.log(canvas, canvas.width, canvas.height, blob) + this.replaceTaggedSize(blob, 'display', this.props.data.settings.fullsize.fn) + .then(data => { + this.props.onSettingsChange('multiple', { + crop, + display: data, + }) + this.uploadThumbnail(canvas) + }) + }, 'image/jpeg', DISPLAY_QUALITY) + }) + } + render() { const { data } = this.props - console.log(data) + // console.log(data) return ( <div className='imageForm'> {!data.url && - <FileInputField - title='Upload image' - onChange={this.handleUpload} - /> + <label className={'text fileInput'}> + <span>{"Upload image"}</span> + <div className="row"> + <button> + {"Choose image"} + </button> + <UploadImage + onUpload={this.handleUpload} + maxSide={DISPLAY_SIZE} + quality={DISPLAY_QUALITY} + /> + </div> + </label> } {data.settings.fullsize && <div> <ImageSelection url={data.settings.fullsize.url} crop={data.settings.crop} + onCrop={this.handleCrop} /> </div> } diff --git a/animism-align/frontend/views/media/media.css b/animism-align/frontend/views/media/media.css index 251afd6..e6e6f5d 100644 --- a/animism-align/frontend/views/media/media.css +++ b/animism-align/frontend/views/media/media.css @@ -24,6 +24,9 @@ .imageForm { background: #315; } +.imageForm .fileInput .row { + position: relative; +} /* video form */ diff --git a/animism-align/frontend/views/paragraph/components/paragraph.types.js b/animism-align/frontend/views/paragraph/components/paragraph.types.js index 6fd8558..fe8158a 100644 --- a/animism-align/frontend/views/paragraph/components/paragraph.types.js +++ b/animism-align/frontend/views/paragraph/components/paragraph.types.js @@ -44,7 +44,6 @@ export const MediaVideo = ({ paragraph, media, selectedParagraph, selectedAnnota const annotation = paragraph.annotations[0] const item = media.lookup[annotation.settings.media_id] if (!item) return <div>Media not found: {annotation.settings.media_id}</div> - console.log(item) return ( <div className={className} |
