import Component from '@glimmer/component'; import extractVideoMetadata from '../utils/extract-video-metadata'; import {IMAGE_EXTENSIONS, IMAGE_MIME_TYPES} from 'ghost-admin/components/gh-image-uploader'; import {TrackedObject} from 'tracked-built-ins'; import {action} from '@ember/object'; import {bind} from '@ember/runloop'; import {guidFor} from '@ember/object/internals'; import {htmlSafe} from '@ember/template'; import {isBlank} from '@ember/utils'; import {inject as service} from '@ember/service'; import {set} from '@ember/object'; import {task} from 'ember-concurrency-decorators'; import {tracked} from '@glimmer/tracking'; export const VIDEO_EXTENSIONS = ['mp4', 'webm', 'ogv']; export const VIDEO_MIME_TYPES = ['video/mp4', 'video/webm', 'video/ogg']; const PLACEHOLDERS = ['summer', 'mountains', 'ufo-attack']; /* Payload { src: 'https://ghostsite.com/media/...', fileName: '...', width: 640, height: 480, duration: 60, mimeType: 'video/mp4' thumbnailSrc: 'https://ghostsite.com/images/...', thumbnailWidth: 640, thumbnailHeight: 640, customThumbnailSrc: 'https://ghostsite.com/images/...', customThumbnailWdith: 640, customThumbnailHeight: 480, cardWidth: 'normal|wide|full', loop: true|false (default: false) } `thumbnail*` are automatically generated client-side when a video is selected */ // TODO: query file size limit from config and forbid uploads before they start export default class KoenigCardVideoComponent extends Component { @service ajax; @service ghostPaths; @tracked files; @tracked isDraggedOver = false; @tracked previewThumbnailSrc; // previewPayload stores all of the data collected until upload completes // at which point it will be saved to the real payload and the preview deleted @tracked previewPayload = new TrackedObject({}); videoExtensions = VIDEO_EXTENSIONS; videoMimeTypes = VIDEO_MIME_TYPES; imageExtensions = IMAGE_EXTENSIONS; imageMimeTypes = IMAGE_MIME_TYPES; placeholder = PLACEHOLDERS[Math.floor(Math.random() * PLACEHOLDERS.length)] payloadVideoAttrs = ['src', 'fileName', 'width', 'height', 'duration', 'mimeType', 'thumbnailSrc', 'thumbnailWidth', 'thumbnailHeight']; get isEmpty() { return isBlank(this.args.payload.src); } get isIncomplete() { const {src, thumbnailSrc} = this.args.payload; return isBlank(src) || isBlank(thumbnailSrc); } get toolbar() { if (this.args.isEditing) { return false; } return { items: [{ buttonClass: 'fw4 flex items-center white', icon: 'koenig/kg-edit', iconClass: 'fill-white', title: 'Edit', text: '', action: bind(this, this.args.editCard) }] }; } get aspectRatioPaddingStyle() { const {width, height} = this.args.payload || {}; return htmlSafe(`padding-top: calc(${height} / ${width} * 100%)`); } get totalDuration() { let duration = this.args.payload.duration || this.previewPayload.duration; return this._getFormattedDuration(duration); } constructor() { super(...arguments); this.args.registerComponent(this); const payloadDefaults = { loop: false }; Object.entries(payloadDefaults).forEach(([key, value]) => { if (this.args.payload[key] === undefined) { this.updatePayloadAttr(key, value); } }); } @action didInsert(element) { // required for snippet rects to be calculated - editor reaches in to component, // expecting a non-Glimmer component with a .element property this.element = element; const {triggerBrowse, src, files} = this.args.payload; // don't persist editor-only payload attrs delete this.args.payload.triggerBrowse; delete this.args.payload.files; // the editor will add a triggerBrowse payload attr when inserting from // the card menu to save an extra click needed to open the file dialog if (triggerBrowse && !src && !files) { this.triggerVideoFileDialog(); } // payload.files will be present if we have an externally set video that // should be uploaded. Typically from a paste or drag/drop if (files) { this.files = files; } } @action registerVideoFileInput(input) { this._videoFileInput = input; } @action triggerVideoFileDialog(event) { if (this._videoFileInput) { return this._videoFileInput.click(); } const target = event?.target || this.element; const cardElem = target.closest('.__mobiledoc-card'); const fileInput = cardElem?.querySelector('input[type="file"]'); if (fileInput) { fileInput.click(); } } @action async videoUploadStarted(files) { // extract metadata into temporary payload whilst video is uploading const file = files[0]; if (file) { // use a task here so we can wait for it later if the upload is quicker const metadata = await this.extractVideoMetadataTask.perform(file); this.previewPayload.duration = metadata.duration; this.previewPayload.width = metadata.width; this.previewPayload.height = metadata.height; this.previewPayload.mimeType = metadata.mimeType; if (metadata.thumbnailBlob) { // show the thumbnail behind the progress bar whilst uploading if (!this.previewPayload.thumbnailSrc) { this.previewThumbnailSrc = URL.createObjectURL(metadata.thumbnailBlob); } // store the thumbnail ready for upload once the video upload completes // TODO: update gh-uploader or switch approach to allow both files // to upload in the same request this._thumbnailBlob = metadata.thumbnailBlob; } } } @action async videoUploadCompleted([video]) { this.previewPayload.src = video.url; this.previewPayload.fileName = video.fileName; // upload can complete before thumbnail is extracted when running locally await this.extractVideoMetadataTask.last; if (this._thumbnailBlob) { try { // upload thumbnail only once video is uploaded because we need to // provide the associated video url for the server to match video+thumbnail const thumbnailSrc = await this.uploadThumbnailFromBlobTask.perform(video.url, this._thumbnailBlob); this.previewPayload.thumbnailSrc = thumbnailSrc; this.previewPayload.thumbnailWidth = this.previewPayload.width; this.previewPayload.thumbnailHeight = this.previewPayload.height; } catch (e) { // thumbnail upload is optional, log the error and move on console.error(e); // eslint-disable-line } finally { this._thumbnailBlob = null; this.previewThumbnailSrc = null; } } // save preview payload attrs into actual payload and create undo snapshot this.args.editor.run(() => { this.payloadVideoAttrs.forEach((attr) => { this.updatePayloadAttr(attr, this.previewPayload[attr]); }); }); // reset preview so we're back to rendering saved data this.previewPayload = new TrackedObject({}); } @action videoUploadFailed() { // reset all attrs, creating an undo snapshot this.args.editor.run(() => { this.payloadVideoAttrs.forEach((attr) => { this.updatePayloadAttr(attr, null); }); }); } @task *extractVideoMetadataTask(file) { return yield extractVideoMetadata(file); } @task *uploadThumbnailFromBlobTask(videoUrl, fileBlob) { const formData = new FormData(); formData.append('file', fileBlob, `media-thumbnail-${guidFor(this)}.jpg`); const url = `${this.ghostPaths.apiRoot}/images/upload/`; const response = yield this.ajax.post(url, { data: formData, processData: false, contentType: false, dataType: 'json' }); return response.images[0].url; } @action async customThumbnailUploadStarted() { // TODO: get image dimensions } @action async customThumbnailUploadCompleted([image]) { this.args.editor.run(() => { this.updatePayloadAttr('customThumbnailSrc', image.url); }); } @action toggleLoop() { this.updatePayloadAttr('loop', !this.args.payload.loop); } @action deleteCustomThumbnail(event) { event?.stopPropagation(); this.updatePayloadAttr('customThumbnailSrc', null); } @action updatePayloadAttr(attr, value) { const {payload} = this.args; set(payload, attr, value); // update the mobiledoc and stay in edit mode this.args.saveCard(payload, false); } @action dragOver(event) { if (!event.dataTransfer) { return; } event.stopPropagation(); event.preventDefault(); this.isDraggedOver = true; } @action dragLeave(event) { event.preventDefault(); this.isDraggedOver = false; } @action drop(event) { event.preventDefault(); event.stopPropagation(); this.isDraggedOver = false; if (event.dataTransfer.files) { this.files = [event.dataTransfer.files[0]]; } } _getFormattedDuration(duration = 200) { const minutes = Math.floor(duration / 60); const seconds = Math.floor(duration - (minutes * 60)); const paddedSeconds = String(seconds).padStart(2, '0'); const formattedDuration = `${minutes}:${paddedSeconds}`; return formattedDuration; } }