mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-02-24 23:48:13 -05:00
Added beginnings of the video card
refs https://github.com/TryGhost/Team/issues/1229 - mostly mirrors image card functionality but rebuilt with more modern syntax - when uploading a video the size and duration is extracted along with a screen capture of the video from 0.5s in, the screen capture is uploaded once the video finishes because we need to use the uploaded video url as a reference to attach it as a thumbnail via the API - the captured screenshot is currently what's shown in the card To be implemented: - "incomplete" state when video has been uploaded but not a thumbnail - uploader in settings panel to change the video thumbnail - play button overlay _or_ switch to `<video>` so it can be previewed
This commit is contained in:
parent
703ee85a7c
commit
3231abd0ae
5 changed files with 442 additions and 7 deletions
|
@ -49,7 +49,7 @@
|
|||
{{#if (or uploader.errors uploader.isUploading (not this.payload.src))}}
|
||||
<div class="relative miw-100 flex items-center {{if (not this.previewSrc this.payload.src) "kg-media-placeholder ba b--whitegrey" "absolute absolute--fill bg-white-50"}}">
|
||||
{{#if uploader.errors}}
|
||||
<span class="db absolute top-0 right-0 left-0 pl2 pr2 bg-red white sans-serif f7">
|
||||
<span class="db absolute top-0 right-0 left-0 flex items-center h8 pl2 pr2 bg-red white sans-serif f7">
|
||||
{{uploader.errors.firstObject.message}}
|
||||
</span>
|
||||
{{/if}}
|
||||
|
|
|
@ -1 +1,117 @@
|
|||
{{yield}}
|
||||
<KoenigCard
|
||||
@env={{@env}}
|
||||
@class={{concat (kg-style "container-card") " kg-video-card mih10 miw-100 relative " (kg-style "breakout" size=@payload.cardWidth)}}
|
||||
@headerOffset={{@headerOffset}}
|
||||
@toolbar={{this.toolbar}}
|
||||
@payload={{@payload}}
|
||||
@isSelected={{@isSelected}}
|
||||
@isEditing={{@isEditing}}
|
||||
@selectCard={{@selectCard}}
|
||||
@deselectCard={{@deselectCard}}
|
||||
@editCard={{@editCard}}
|
||||
@hasEditMode={{not this.isEmpty}}
|
||||
@saveCard={{@saveCard}}
|
||||
@saveAsSnippet={{@saveAsSnippet}}
|
||||
@addParagraphAfterCard={{@addParagraphAfterCard}}
|
||||
@moveCursorToPrevSection={{@moveCursorToPrevSection}}
|
||||
@moveCursorToNextSection={{@moveCursorToNextSection}}
|
||||
@editor={{@editor}}
|
||||
{{did-insert this.didInsert}}
|
||||
{{on "dragover" this.dragOver}}
|
||||
{{on "dragleave" this.dragLeave}}
|
||||
{{on "drop" this.drop}}
|
||||
as |card|
|
||||
>
|
||||
<GhUploader
|
||||
@uploadUrl="/media/upload/"
|
||||
@resourceName="media"
|
||||
@files={{this.files}}
|
||||
@accept={{this.videoMimeTypes}}
|
||||
@extensions={{this.videoExtensions}}
|
||||
@onStart={{this.videoUploadStarted}}
|
||||
@onComplete={{this.videoUploadCompleted}}
|
||||
@onFailed={{this.videoUploadFailed}}
|
||||
as |uploader|
|
||||
>
|
||||
<div class="relative{{unless (or this.previewThumbnailSrc @payload.thumbnailSrc) " bg-whitegrey-l2"}}">
|
||||
{{#if (or this.previewThumbnailSrc @payload.thumbnailSrc)}}
|
||||
<img src={{or this.previewThumbnailSrc @payload.thumbnailSrc}} class="{{kg-style this.kgImgStyle}}">
|
||||
{{#if this.isDraggedOver}}
|
||||
<div class="absolute absolute--fill flex items-center bg-black-60 pe-none">
|
||||
<span class="db center sans-serif fw7 f7 white">
|
||||
Drop to replace video
|
||||
</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
{{#if (or uploader.errors uploader.isUploading (not @payload.src))}}
|
||||
<div class="relative miw-100 flex items-center {{if (not this.previewThumbnailSrc @payload.src) "kg-media-placeholder ba b--whitegrey" "absolute absolute--fill bg-white-50"}}">
|
||||
{{#if uploader.errors}}
|
||||
<span class="db absolute top-0 right-0 left-0 flex items-center h8 pl2 pr2 bg-red white sans-serif f7">
|
||||
{{uploader.errors.firstObject.message}}
|
||||
</span>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.isDraggedOver}}
|
||||
<span class="db center sans-serif fw7 f7 middarkgrey">
|
||||
Drop it like it's hot 🔥
|
||||
</span>
|
||||
{{else if uploader.isUploading}}
|
||||
{{uploader.progressBar}}
|
||||
{{else if (not this.previewThumbnailSrc @payload.src)}}
|
||||
<button class="flex flex-column items-center center sans-serif fw4 f7 middarkgrey pa16 pt14 pb14 kg-image-button" {{on "click" this.triggerVideoFileDialog}}>
|
||||
{{svg-jar this.placeholder class="kg-placeholder-image"}}
|
||||
<span class="mt2 midgrey">Click to select a video</span>
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div style="display:none">
|
||||
<GhFileInput @multiple={{false}} @action={{uploader.setFiles}} @accept={{this.videoMimeTypes}} @onInsert={{this.registerVideoFileInput}} />
|
||||
</div>
|
||||
</GhUploader>
|
||||
|
||||
{{#if (or @isSelected (clean-basic-html @payload.caption))}}
|
||||
<card.CaptionInput
|
||||
@caption={{@payload.caption}}
|
||||
@update={{fn this.updatePayloadAttr "caption"}}
|
||||
@placeholder="Type caption for video (optional)" />
|
||||
{{/if}}
|
||||
|
||||
{{#if (and @isEditing @payload.src)}}
|
||||
<KoenigSettingsPanel>
|
||||
<div class="kg-settings-panel-control kg-settings-panel-control-horizontal">
|
||||
<div class="kg-settings-panel-control-label">Video width</div>
|
||||
<div class="kg-settings-panel-control-input">
|
||||
<div class="gh-btn-group icons">
|
||||
<button type="button" title="Regular" class="gh-btn gh-btn-icon {{if (not @payload.cardWidth) "gh-btn-group-selected"}}" {{on "click" (fn this.updatePayloadAttr "cardWidth" null)}}><span>{{svg-jar "koenig/kg-img-regular"}}</span></button>
|
||||
<button type="button" title="Wide" class="gh-btn gh-btn-icon {{if (eq @payload.cardWidth "wide") "gh-btn-group-selected"}}" {{on "click" (fn this.updatePayloadAttr "cardWidth" "wide")}}><span>{{svg-jar "koenig/kg-img-wide"}}</span></button>
|
||||
<button type="button" title="Full" class="gh-btn gh-btn-icon {{if (eq @payload.cardWidth "full") "gh-btn-group-selected"}}" {{on "click" (fn this.updatePayloadAttr "cardWidth" "full")}}><span>{{svg-jar "koenig/kg-img-full"}}</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kg-settings-panel-control kg-settings-panel-control-horizontal">
|
||||
<label class="kg-settings-panel-control-label" for="loop-toggle">Loop</label>
|
||||
<div class="kg-settings-panel-control-input">
|
||||
<div class="for-switch x-small">
|
||||
<label class="switch">
|
||||
<input type="checkbox" class="gh-input" id="loop-toggle" checked={{@payload.loop}} {{on "input" this.toggleLoop}}>
|
||||
<span class="input-toggle-component mt1"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kg-settings-panel-control">
|
||||
<label class="kg-settings-panel-control-label" for="button-url-input">Poster image</label>
|
||||
<div class="kg-settings-panel-control-input">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</KoenigSettingsPanel>
|
||||
{{/if}}
|
||||
</KoenigCard>
|
||||
|
|
|
@ -1,10 +1,282 @@
|
|||
import Component from '@glimmer/component';
|
||||
import extractVideoMetadata from '../utils/extract-video-metadata';
|
||||
import {TrackedObject} from 'tracked-built-ins';
|
||||
import {action} from '@ember/object';
|
||||
import {bind} from '@ember/runloop';
|
||||
import {guidFor} from '@ember/object/internals';
|
||||
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,
|
||||
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 config;
|
||||
@service feature;
|
||||
@service store;
|
||||
@service membersUtils;
|
||||
@service ui;
|
||||
@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;
|
||||
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)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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`);
|
||||
formData.append('url', videoUrl);
|
||||
|
||||
const url = `${this.ghostPaths.apiRoot}/media/thumbnail/upload/`;
|
||||
|
||||
const response = yield this.ajax.put(url, {
|
||||
data: formData,
|
||||
processData: false,
|
||||
contentType: false,
|
||||
dataType: 'json'
|
||||
});
|
||||
|
||||
return response.media[0].url;
|
||||
}
|
||||
|
||||
@action
|
||||
toggleLoop() {
|
||||
this.updatePayloadAttr('loop', !this.args.payload.loop);
|
||||
}
|
||||
|
||||
@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]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -227,6 +227,9 @@ export const CARD_MENU = [
|
|||
matches: ['video'],
|
||||
type: 'card',
|
||||
replaceArg: 'video',
|
||||
payload: {
|
||||
triggerBrowse: true
|
||||
},
|
||||
isAvailable: 'feature.videoCard'
|
||||
},
|
||||
{
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
export default function extractVideoMetadata(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let duration, width, height;
|
||||
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'metadata';
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
|
||||
video.onerror = reject;
|
||||
|
||||
video.onloadedmetadata = function () {
|
||||
duration = video.duration;
|
||||
width = video.videoWidth;
|
||||
height = video.videoHeight;
|
||||
|
||||
setTimeout(() => {
|
||||
video.currentTime = 0.5;
|
||||
}, 200);
|
||||
};
|
||||
|
||||
video.onseeked = function () {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(video, 0, 0, width, height);
|
||||
|
||||
window.URL.revokeObjectURL(video.src);
|
||||
|
||||
ctx.canvas.toBlob((thumbnailBlob) => {
|
||||
resolve({
|
||||
duration,
|
||||
width,
|
||||
height,
|
||||
thumbnailBlob
|
||||
});
|
||||
}, 'image/jpeg', 0.75);
|
||||
};
|
||||
|
||||
video.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
Loading…
Add table
Reference in a new issue