diff --git a/__tests__/displays/VideoDisplay.spec.js b/__tests__/displays/VideoDisplay.spec.js new file mode 100644 index 00000000..bec83819 --- /dev/null +++ b/__tests__/displays/VideoDisplay.spec.js @@ -0,0 +1,45 @@ +/** + * @jest-environment jsdom + */ +import VideoDisplay from 'displays/VideoDisplay'; +import { BLANK_IMAGE } from 'view/constants'; + +jest.mock('three', () => ({ + VideoTexture: class {}, +})); + +jest.mock('core/WebGLDisplay', () => { + return class WebGLDisplay { + constructor(display, properties) { + this.properties = { ...display.config.defaultProperties, ...properties }; + } + update() {} + dispose() {} + }; +}); +jest.mock('graphics/ImagePass', () => class {}); + +describe('VideoDisplay', () => { + it('should be a display', () => { + const display = new VideoDisplay(); + expect(display.constructor.config.type).toBe('display'); + }); + + it('should have default properties', () => { + const display = new VideoDisplay(); + expect(display.properties).toEqual({ + src: BLANK_IMAGE, + x: 0, + y: 0, + zoom: 1, + width: 0, + height: 0, + fixed: true, + rotation: 0, + opacity: 0, + loop: true, + startTime: 0, + endTime: 0, + }); + }); +}); diff --git a/src/displays/VideoDisplay.js b/src/displays/VideoDisplay.js new file mode 100644 index 00000000..c6b1aed4 --- /dev/null +++ b/src/displays/VideoDisplay.js @@ -0,0 +1,221 @@ +import { VideoTexture } from 'three'; +import WebGLDisplay from 'core/WebGLDisplay'; +import { BLANK_IMAGE } from 'view/constants'; +import ImagePass from 'graphics/ImagePass'; +import { deg2rad } from 'utils/math'; + +const disabled = display => !display.hasVideo; + +export default class VideoDisplay extends WebGLDisplay { + static config = { + name: 'VideoDisplay', + description: 'Displays a video.', + type: 'display', + label: 'Video', + defaultProperties: { + src: BLANK_IMAGE, + x: 0, + y: 0, + zoom: 1, + width: 0, + height: 0, + fixed: true, + rotation: 0, + opacity: 0, + loop: true, + startTime: 0, + endTime: 0, + }, + controls: { + src: { + label: 'Video', + type: 'video', + }, + width: { + label: 'Width', + type: 'number', + min: 0, + max: 4096, + withRange: true, + withLink: 'fixed', + disabled, + }, + height: { + label: 'Height', + type: 'number', + min: 0, + max: 4096, + withRange: true, + withLink: 'fixed', + disabled, + }, + x: { + label: 'X', + type: 'number', + min: -4096, + max: 4096, + withRange: true, + disabled, + }, + y: { + label: 'Y', + type: 'number', + min: -4096, + max: 4096, + withRange: true, + disabled, + }, + zoom: { + label: 'Zoom', + type: 'number', + min: 1.0, + max: 4.0, + step: 0.01, + withRange: true, + withReactor: true, + disabled, + }, + rotation: { + label: 'Rotation', + type: 'number', + min: 0, + max: 360, + withRange: true, + withReactor: true, + disabled, + }, + opacity: { + label: 'Opacity', + type: 'number', + min: 0, + max: 1.0, + step: 0.01, + withRange: true, + withReactor: true, + disabled, + }, + loop: { + label: 'Loop', + type: 'toggle', + disabled, + }, + startTime: { + label: 'Start Time', + type: 'number', + min: 0, + max: 1000, + step: 0.1, + withRange: true, + disabled, + }, + endTime: { + label: 'End Time', + type: 'number', + min: 0, + max: 1000, + step: 0.1, + withRange: true, + disabled, + }, + }, + }; + + constructor(properties) { + super(VideoDisplay, properties); + + this.video = document.createElement('video'); + this.video.src = this.properties.src; + this.video.loop = this.properties.loop; + this.video.muted = true; + this.video.play(); + + this.video.addEventListener('timeupdate', this.handleTimeUpdate); + } + + get hasVideo() { + return this.properties.src !== BLANK_IMAGE; + } + + handleTimeUpdate = () => { + const { loop, startTime, endTime } = this.properties; + + if (loop && endTime > 0 && this.video.currentTime >= endTime) { + this.video.currentTime = startTime; + } + }; + + update(properties) { + const changed = super.update(properties); + + if (changed) { + const { src, loop, startTime, endTime, opacity, zoom, width, height, x, y, rotation } = properties; + + if (src) { + this.video.src = src; + } + + if (loop !== undefined) { + this.video.loop = loop; + } + + if (startTime !== undefined || endTime !== undefined) { + this.video.currentTime = this.properties.startTime; + } + + if (src) { + const texture = new VideoTexture(this.video); + const { width, height } = this.scene.getSize(); + this.pass = new ImagePass(texture, { width, height }); + this.pass.camera.aspect = width / height; + this.pass.camera.updateProjectionMatrix(); + } + if (zoom !== undefined) { + const { camera } = this.pass; + camera.zoom = zoom; + camera.updateProjectionMatrix(); + } + if (width) { + this.pass.mesh.scale.x = width / this.video.videoWidth; + } + if (height) { + this.pass.mesh.scale.y = height / this.video.videoHeight; + } + if (opacity) { + this.pass.material.opacity = opacity; + } + if (x !== undefined) { + this.pass.mesh.position.x = x; + } + if (y !== undefined) { + this.pass.mesh.position.y = y; + } + if (rotation !== undefined) { + this.pass.mesh.rotation.z = deg2rad(-rotation); + } + } + + return changed; + } + + addToScene({ getSize }) { + const { width, height } = getSize(); + + const texture = new VideoTexture(this.video); + + this.pass = new ImagePass(texture, { width, height }); + + this.setSize(width, height); + } + + setSize(width, height) { + if (this.pass) { + this.pass.camera.aspect = width / height; + this.pass.camera.updateProjectionMatrix(); + } + } + + dispose() { + this.video.removeEventListener('timeupdate', this.handleTimeUpdate); + super.dispose(); + } +} diff --git a/src/displays/index.js b/src/displays/index.js index da64f118..249d1ab8 100644 --- a/src/displays/index.js +++ b/src/displays/index.js @@ -1,6 +1,7 @@ export BarSpectrumDisplay from './BarSpectrumDisplay'; export GeometryDisplay from './GeometryDisplay'; export ImageDisplay from './ImageDisplay'; +export VideoDisplay from './VideoDisplay'; export ShapeDisplay from './ShapeDisplay'; export SoundWaveDisplay from './SoundWaveDisplay'; export TextDisplay from './TextDisplay'; diff --git a/src/graphics/ImagePass.js b/src/graphics/ImagePass.js index 2868104e..a3af8e92 100644 --- a/src/graphics/ImagePass.js +++ b/src/graphics/ImagePass.js @@ -1,4 +1,5 @@ import { + LinearFilter, MeshBasicMaterial, OrthographicCamera, PlaneBufferGeometry, @@ -14,6 +15,8 @@ export default class ImagePass extends Pass { this.texture = texture; + texture.minFilter = LinearFilter; + const material = new MeshBasicMaterial({ map: texture, depthTest: false, diff --git a/src/main/api/index.js b/src/main/api/index.js index effddbb2..c51a1418 100644 --- a/src/main/api/index.js +++ b/src/main/api/index.js @@ -1,6 +1,7 @@ export { loadAudioTags, readAudioFile } from './audio'; export { loadConfig, saveConfig } from './config'; export { readImageFile, saveImageFile } from './image'; +export { readVideoFile } from './video'; export { loadProjectFile, saveProjectFile } from './project'; export { send, on, once, off, invoke, log, getGlobal } from './ipc'; export { loadPlugins, getPlugins } from './plugin'; diff --git a/src/main/api/video.js b/src/main/api/video.js new file mode 100644 index 00000000..a81150b6 --- /dev/null +++ b/src/main/api/video.js @@ -0,0 +1,14 @@ +import path from 'path'; +import { readFile } from 'utils/io'; +import { blobToDataUrl, dataToBlob } from 'utils/data'; + +export async function readVideoFile(file) { + const fileData = await readFile(file); + const blob = await dataToBlob(fileData, path.extname(file)); + + if (!/^video/.test(blob.type)) { + throw new Error('Invalid video file.'); + } + + return blobToDataUrl(blob); +} diff --git a/src/view/components/controls/inputComponents.js b/src/view/components/controls/inputComponents.js index 2471fbc5..8c0b7b4d 100644 --- a/src/view/components/controls/inputComponents.js +++ b/src/view/components/controls/inputComponents.js @@ -3,6 +3,7 @@ import { ColorInput, ColorRangeInput, ImageInput, + VideoInput, NumberInput, RangeInput, SelectInput, @@ -21,6 +22,7 @@ const inputComponents = { range: [RangeInput], select: [SelectInput, { width: 140 }], image: [ImageInput], + video: [VideoInput], time: [TimeInput], }; diff --git a/src/view/components/inputs/VideoInput.js b/src/view/components/inputs/VideoInput.js new file mode 100644 index 00000000..e244d720 --- /dev/null +++ b/src/view/components/inputs/VideoInput.js @@ -0,0 +1,92 @@ +import React, { useRef } from 'react'; +import classNames from 'classnames'; +import Icon from 'components/interface/Icon'; +import { raiseError } from 'actions/error'; +import { ignoreEvents } from 'utils/react'; +import { api } from 'view/global'; +import { FolderOpen, Times } from 'view/icons'; +import { BLANK_IMAGE } from 'view/constants'; +import styles from './ImageInput.less'; + +export default function VideoInput({ name, value, onChange }) { + const video = useRef(); + const hasVideo = value !== BLANK_IMAGE; + + function handleVideoLoad() { + onChange(name, video.current); + } + + function loadVideoSrc(src) { + if (video.current.src !== src) { + video.current.src = src; + } + } + + async function loadVideoFile(file) { + try { + const dataUrl = await api.readVideoFile(file); + + return loadVideoSrc(dataUrl); + } catch (error) { + raiseError('Invalid video file.', error); + } + } + + async function handleDrop(e) { + e.preventDefault(); + + await loadVideoFile(e.dataTransfer.files[0].path); + } + + async function handleClick() { + const { filePaths, canceled } = await api.showOpenDialog({ + properties: ['openFile'], + filters: [ + { name: 'Videos', extensions: ['mp4', 'webm', 'ogv'] }, + ], + }); + + if (!canceled) { + await loadVideoFile(filePaths[0]); + } + } + + function handleDelete() { + loadVideoSrc(BLANK_IMAGE); + } + + return ( + <> +
+
+ {hasVideo && ( + + )} + + ); +} diff --git a/src/view/components/inputs/index.js b/src/view/components/inputs/index.js index 66d6a888..d90050a5 100644 --- a/src/view/components/inputs/index.js +++ b/src/view/components/inputs/index.js @@ -5,6 +5,7 @@ export CheckboxInput from './CheckboxInput'; export ColorInput from './ColorInput'; export ColorRangeInput from './ColorRangeInput'; export ImageInput from './ImageInput'; +export VideoInput from './VideoInput'; export NumberInput from './NumberInput'; export RangeInput from './RangeInput'; export ReactorButton from './ReactorButton'; diff --git a/src/view/components/panels/ReactorPanel.js b/src/view/components/panels/ReactorPanel.js index c518db1b..b34fa7d3 100644 --- a/src/view/components/panels/ReactorPanel.js +++ b/src/view/components/panels/ReactorPanel.js @@ -7,7 +7,7 @@ import CanvasBars from 'canvas/CanvasBars'; import CanvasMeter from 'canvas/CanvasMeter'; import useApp, { setActiveReactorId } from 'actions/app'; import { events, reactors } from 'global'; -import { ChevronDown } from 'icons'; +import { ChevronDown } from 'view/icons'; import { PRIMARY_COLOR, REACTOR_BARS,