Ghost-Admin/lib/koenig-editor/addon/components/koenig-card-video.js

328 lines
10 KiB
JavaScript

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';
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;
}
}