scripts/extensions/videoEditor/src/VideoEditorThumbnail.tsx
import * as React from 'react';
import {IArticle, ISuperdesk} from 'superdesk-api';
import {IErrorMessage, ICrop, IVideoProject} from './interfaces';
import {pick} from 'lodash';
interface IProps {
video: HTMLVideoElement;
article: IArticle;
crop: ICrop;
rotate: number;
superdesk: ISuperdesk;
onToggleLoading: (isLoading: boolean, loadingText?: string, type?: 'video' | 'thumbnail') => void;
onSave: (renditions: IArticle['renditions'], etag: string) => void;
onError: (err: IErrorMessage) => void;
getCropRotate: (crop: ICrop) => ICrop;
}
interface IState {
type: 'capture' | 'upload' | null;
value: number | File; // capture positon or uploaded File
rotateDegree: number; // save current rotate degree when user captures thumbnail
scale: number;
}
interface IThumbnailCaptureParams {
position?: number;
crop?: string;
rotate?: number;
}
const initialState: IState = {type: null, value: 0, rotateDegree: 0, scale: 1};
export class VideoEditorThumbnail extends React.Component<IProps, IState> {
private ref: React.RefObject<HTMLCanvasElement>;
private maxCanvasSize: { width: number; height: number };
private interval: number;
constructor(props: IProps) {
super(props);
this.state = initialState;
this.ref = React.createRef();
this.maxCanvasSize = {width: 0, height: 0};
this.interval = 0;
this.handleClick = this.handleClick.bind(this);
this.handleUpload = this.handleUpload.bind(this);
this.handleSave = this.handleSave.bind(this);
this.handleReset = this.handleReset.bind(this);
this.getWrapperSize = this.getWrapperSize.bind(this);
}
componentDidMount() {
const thumbnail = this.props.article.renditions?.viewImage?.href;
if (thumbnail) {
this.setThumbnail(thumbnail);
}
}
componentDidUpdate(prevProps: IProps) {
// video has changed so captured thumbnail showed on UI is no longer correct
if (prevProps.article.renditions !== this.props.article.renditions && this.state.type === 'capture') {
this.handleReset();
}
}
componentWillUnmount() {
if (this.interval) {
clearInterval(this.interval);
}
}
handleClick() {
this.setState(
{
type: 'capture',
value: this.props.video.currentTime,
rotateDegree: this.props.rotate,
},
() => {
// make sure rotateDegree is updated so we can calculate scale ratio correctly
const video = this.props.video;
let {x, y, width, height, aspect} = this.props.getCropRotate(this.props.crop);
aspect = aspect ?? 1;
let canvasSize = [this.maxCanvasSize.width, this.maxCanvasSize.height];
// crop is disabled or has not drew crop area yet
const isDisabledCrop = [x, y, width, height].every((value) => value === 0);
if (isDisabledCrop) {
aspect = video.videoWidth / video.videoHeight;
}
if (this.state.rotateDegree % 180 !== 0) {
if (!isDisabledCrop) {
aspect = 1 / aspect;
}
// make thumbnail overflow then scale it down, otherwise thumbnail will be too small
// once rotated because we draw thumbnail based on canvas width
canvasSize = [this.maxCanvasSize.height, this.maxCanvasSize.width];
}
this.drawCanvas(
video,
x || 0,
y || 0,
width || video.videoWidth,
height || video.videoHeight,
aspect,
canvasSize,
);
},
);
}
handleUpload(files: FileList | null) {
const file = files?.[0];
if (file == null) {
return;
}
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === 'string') {
this.setThumbnail(reader.result);
}
};
reader.readAsDataURL(file);
this.setState({...initialState, value: file, type: 'upload'});
}
handleSave() {
const {gettext} = this.props.superdesk.localization;
const {session, instance} = this.props.superdesk;
const host = instance.config.server.url;
if (this.state.type === 'capture' && typeof this.state.value === 'number') {
const crop = this.props.getCropRotate(pick(this.props.crop, ['x', 'y', 'width', 'height']));
const body: IThumbnailCaptureParams = {
// Captured thumbnail from server and from canvas have small difference in time (position)
position: this.state.value - 0.04,
crop: Object.values(crop).join(','),
rotate: this.state.rotateDegree,
};
if (body.crop === '0,0,0,0') {
delete body.crop;
}
if (body.rotate === 0) {
delete body.rotate;
}
// dataApi.create requires both body and response is the same type
fetch(`${host}/video_edit`, {
method: 'POST',
headers: {
Authorization: session.getToken(),
'Content-Type': 'application/json',
'If-Match': this.props.article._etag,
},
body: JSON.stringify({
capture: body,
item: {
_id: this.props.article._id,
renditions: this.props.article.renditions,
},
}),
})
.then(() => {
// reuse thumbnail from canvas so we don't have to display the old one,
// new thumbnail will be loaded when user reset changes
this.setState({
...initialState,
scale: this.state.scale,
rotateDegree: this.props.rotate,
});
this.props.onToggleLoading(true, gettext('Saving capture thumbnail...'), 'thumbnail');
this.getThumbnail();
})
.catch(this.props.onError);
} else if (this.state.type === 'upload' && this.state.value instanceof File) {
const form = new FormData();
form.append('file', this.state.value);
fetch(`${host}/video_edit/${this.props.article._id}`, {
method: 'PUT',
headers: {
Authorization: session.getToken(),
'If-Match': this.props.article._etag,
},
body: form,
})
.then((res) => res.json())
.then((res: IVideoProject) => {
this.handleReset();
this.props.onSave(res.renditions, res._etag);
this.setThumbnail(res.renditions?.viewImage?.href ?? '');
})
.catch(this.props.onError);
}
}
handleReset() {
this.clearCanvas();
this.setState({
...initialState,
scale: this.state.scale,
});
}
setThumbnail(src: string) {
const image = new Image();
image.onload = () => {
this.drawCanvas(image, 0, 0, image.width, image.height);
};
image.src = src;
// file reader result
if (src.startsWith('data:') === false) {
// Firefox does not reload image even no-store cache-control header is set
image.src = src + `?t=${Math.random()}`;
}
}
getThumbnail() {
const {dataApi} = this.props.superdesk;
this.interval = window.setInterval(() => {
dataApi
.findOne<IVideoProject>('video_edit', this.props.article._id)
.then((response: IVideoProject) => {
if (response.project?.processing?.thumbnail_preview === false) {
clearInterval(this.interval);
this.props.onSave(response.renditions, response._etag);
this.props.onToggleLoading(false, '', 'thumbnail');
}
})
.catch((_: IErrorMessage) => {
clearInterval(this.interval);
});
}, 1500);
}
drawCanvas(
element: HTMLImageElement | HTMLVideoElement,
x: number,
y: number,
width: number,
height: number,
ratio: number = width / height,
canvasSize: Array<number> = [this.maxCanvasSize.width, this.maxCanvasSize.height],
) {
if (this.ref.current == null) {
throw new Error('Could not get current canvas');
}
let [drawWidth, drawHeight] = canvasSize;
if (ratio > 1) {
drawHeight = drawWidth / ratio;
} else {
drawWidth = drawHeight * ratio;
}
this.ref.current.width = drawWidth;
this.ref.current.height = drawHeight;
this.ref.current.getContext('2d')?.drawImage(element, x, y, width, height, 0, 0, drawWidth, drawHeight);
this.setScale(this.ref.current);
}
clearCanvas() {
const ctx = this.ref.current?.getContext('2d');
if (this.ref.current == null || ctx == null) {
throw new Error('Could not get current canvas');
}
ctx.clearRect(0, 0, this.ref.current.width, this.ref.current.height);
const thumbnail = this.props.article.renditions?.viewImage?.href;
if (thumbnail) {
this.setThumbnail(thumbnail);
}
}
// get wrapper size dynamically to use for calculating canvas size to fit content into
getWrapperSize(element: HTMLDivElement) {
if (element == null) {
return;
}
const {width, height} = element.getBoundingClientRect();
this.maxCanvasSize = {
width: width,
height: height,
};
}
setScale(ref: HTMLCanvasElement) {
// calculate scale while rotating to make sure image is not exceeded maximum wrapper size
let scale = 1;
const height = ref.getBoundingClientRect().height / this.state.scale;
if (height > this.maxCanvasSize.height) {
scale = this.maxCanvasSize.height / height;
} else if (height === this.maxCanvasSize.height && this.state.scale < 1) {
// scale was calculated on previous call
return;
}
this.setState({scale: scale});
}
render() {
const {getClass} = this.props.superdesk.utilities.CSS;
const {gettext} = this.props.superdesk.localization;
return (
<div className={`sd-photo-preview__thumbnail-edit ${getClass('thumbnail__container')}`}>
<div className="sd-photo-preview__thumbnail-edit-label">{gettext('Video thumbnail')}</div>
<div className="image-overlay">
<div className="image-overlay__button-block">
{this.state.type === null ? (
<>
<a
className="image-overlay__button"
sd-tooltip={gettext('Use current frame')}
onClick={this.handleClick}
>
<i className="icon-photo" />
</a>
<form className="sd-margin-start--1">
<label>
<input
type="file"
style={{display: 'none'}}
accept=".png,.jpg,.jpeg,.webp"
onChange={(e) => this.handleUpload(e.target.files)}
/>
<a className="image-overlay__button" sd-tooltip={gettext('Upload image')}>
<i className="icon-upload" />
</a>
</label>
</form>
</>
) : (
<>
<a
className="image-overlay__button"
sd-tooltip={gettext('Save change')}
onClick={this.handleSave}
>
<i className="icon-ok" />
</a>
<a
className="image-overlay__button"
sd-tooltip={gettext('Reset change')}
onClick={this.handleReset}
>
<i className="icon-close-thick" />
</a>
</>
)}
</div>
</div>
{!this.props.article.renditions?.viewImage?.href && !this.state.value && (
<div className={getClass('thumbnail--empty')}>
<div className="upload__info-icon" />
<p className={getClass('thumbnail--empty__text')}>{gettext('No thumbnail')}</p>
</div>
)}
<div className={getClass('thumbnail__wrapper')} ref={this.getWrapperSize}>
<canvas
ref={this.ref}
style={{transform: `rotate(${this.state.rotateDegree}deg) scale(${this.state.scale})`}}
/>
</div>
</div>
);
}
}