diff --git a/configs/headless-render/webpack.config.js b/configs/headless-render/webpack.config.js new file mode 100644 index 0000000000..43796cba33 --- /dev/null +++ b/configs/headless-render/webpack.config.js @@ -0,0 +1,43 @@ +const path = require('path') +const webpack = require('webpack') + +module.exports = { + entry: './src/js/windows/headless-render/window.js', + target: 'electron-main', + output: { + path: path.resolve(__dirname, './../../src/build'), + filename: 'headless-render.js' + }, + module: { + rules: [ + { + test: /\.(js|jsx)$/, + exclude: /(node_modules|bower_components)/, + use: { + loader: 'babel-loader', + options: { + presets: [ + [ + '@babel/preset-env', + { + targets: { electron: require('electron/package.json').version } + } + ], + '@babel/preset-react' + ], + plugins: ['@babel/plugin-proposal-class-properties'] + } + } + } + ] + }, + node: { + __dirname: false, + __filename: false + }, + plugins: [ + new webpack.ProvidePlugin({ + 'THREE': 'three' + }) + ] +} diff --git a/package.json b/package.json index fca033e45d..db4eb0f1db 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "start:ar": "webpack --mode=development --watch --progress --config configs/ar/webpack.config.js", "start:shot-generator": "webpack --mode=development --watch --progress --config configs/shot-generator/webpack.config.js", "start:shot-explorer": "webpack --mode=development --watch --progress --config configs/shot-explorer/webpack.config.js", + "start:headless-render": "webpack --mode=development --watch --progress --config configs/headless-render/webpack.config.js", "start:server": "cd server && npm run start", "start:electron": "cross-env NODE_ENV=development electron .", "start:language-preferences": "webpack --mode=development --watch --progress --config configs/language-preferences/webpack.config.js", @@ -27,6 +28,7 @@ "build-thumbnails": "electron test/views/thumbnail-renderer/main.js", "build:shot-generator": "webpack --mode=production --progress --config configs/shot-generator/webpack.config.js", "build:shot-explorer": "webpack --mode=production --progress --config configs/shot-explorer/webpack.config.js", + "build:headless-render": "webpack --mode=production --progress --config configs/headless-render/webpack.config.js", "build:xr": "webpack --mode=production --progress --config configs/xr/webpack.config.js", "build:ar": "webpack --mode=production --progress --config configs/ar/webpack.config.js", "build:server": "cd server && npm run build", @@ -225,6 +227,7 @@ "pdfjs-dist": "2.0.550", "pdfkit": "^0.9.0", "peerjs": "^1.3.1", + "peerjs-js-binarypack": "^1.0.1", "plist": "2.1.0", "postfix-calculator": "0.0.2", "promise-cancelable": "2.1.1", diff --git a/src/aspect-settings.html b/src/aspect-settings.html new file mode 100644 index 0000000000..daf27cbe56 --- /dev/null +++ b/src/aspect-settings.html @@ -0,0 +1,122 @@ + + + + + + + aspect settings + + + + +
+
+

What Aspect Ratio?

+ +
+
+
Ultrawide
+
2.39:1
+
+
+
+
+
+
Doublewide
+
2.00:1
+
+
+
+
+
+
Wide
+
1.85:1
+
+
+
+
+
+
HD
+
16:9
+
+
+
+
+
+
Vertical HD
+
9:16
+
+
+
+
+
+
Square
+
1:1
+
+
+
+
+
+
Old
+
4:3
+
+
+
+
+
+ + +
+

+ The aspect ratio defines the size of your boards.

+ 2.39:1 is the widest, like what you would watch in a movie.
+ 16:9 is what you would watch on a modern TV.
+ 4:3 is what your grandpops watched back when screens flickered and programming was wholesome.

+ Please don't select vertical HD. We added it as a joke.
+

+
+
+ +
+ + + diff --git a/src/css/preferences.css b/src/css/preferences.css index 3b49ba4673..8e387eb58d 100644 --- a/src/css/preferences.css +++ b/src/css/preferences.css @@ -168,8 +168,13 @@ a.button:hover { position: relative; display: relative; } +.open-aspect-settings { + position: relative; + display: relative; +} -.open-language-editor button { +.open-language-editor button, +.open-aspect-settings button { background-color: #333333; color: white; padding: 12px; diff --git a/src/css/shot-explorer.css b/src/css/shot-explorer.css index 9a19b74119..30498c21bc 100644 --- a/src/css/shot-explorer.css +++ b/src/css/shot-explorer.css @@ -112,4 +112,26 @@ img, a{ -webkit-user-select: none; /* Safari 3.1+ */ -moz-user-select: none; /* height: 70px; font-size: 18px; color: #9d9d9d; +} + +.camera-view { + height: 100%; + flex: 1; + position: relative; + overflow: hidden; + background-color: #111114; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.camera-view-view { + position: relative; + overflow: hidden; + background-color: #111114; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; } \ No newline at end of file diff --git a/src/headless-render.html b/src/headless-render.html new file mode 100644 index 0000000000..9bd337c4f6 --- /dev/null +++ b/src/headless-render.html @@ -0,0 +1,16 @@ + + + + + + + + Headless Render + + +
+ + + diff --git a/src/js/headless-render/hooks/useSaveImage.js b/src/js/headless-render/hooks/useSaveImage.js new file mode 100644 index 0000000000..eff6a94e9e --- /dev/null +++ b/src/js/headless-render/hooks/useSaveImage.js @@ -0,0 +1,54 @@ +import * as THREE from 'three' +import { useThree } from 'react-three-fiber' + +import { ipcRenderer } from 'electron' +import { + getSerializedState, + } from '../../shared/reducers/shot-generator' +const setCameraAspectFromRendererSize = (renderer, camera) => { + let size = renderer.getSize(new THREE.Vector2()) + camera.aspect = size.width / size.height +} + +const renderShot = (renderer, scene, originalCamera, shotSize) => { + let camera = originalCamera.clone() + + //camera.layers.set(SHOT_LAYERS) + + setCameraAspectFromRendererSize(renderer, camera) + camera.updateProjectionMatrix() + renderer.setSize(shotSize.width, shotSize.height) + renderer.render(scene, camera) + let shotImageDataUrl = renderer.domElement.toDataURL() + return { shotImageDataUrl} +} + +const useSaveImage = (renderer, dispatch) => { + const { camera, scene } = useThree() + + const saveImage = () => (dispatch, getState) => { + let state = getState() + let aspectRatio = state.aspectRatio + let shotSize = new THREE.Vector2(Math.ceil(aspectRatio * 900), 900) + + let {shotImageDataUrl} = renderShot(renderer, scene, camera, shotSize) + let data = getSerializedState(state) + let currentBoard = state.board + let uid = currentBoard.uid + + ipcRenderer.send('saveShot', { + uid, + data, + images: { + camera: shotImageDataUrl + } + }) + + } + + const saveCurrentShotCb = () => dispatch( saveImage()) + return {saveImage:saveCurrentShotCb} + +} + +export default useSaveImage; \ No newline at end of file diff --git a/src/js/headless-render/index.js b/src/js/headless-render/index.js new file mode 100644 index 0000000000..8a177a8b89 --- /dev/null +++ b/src/js/headless-render/index.js @@ -0,0 +1,154 @@ +import React, { useState, useEffect, useRef, useMemo } from 'react' +import { Provider, connect, useSelector } from 'react-redux' +import { Canvas } from 'react-three-fiber' +import { useThree, useFrame } from 'react-three-fiber' +import ShotExplorerSceneManager from '../shot-explorer/ShotExplorerSceneManager' +import FatalErrorBoundary from '../shot-generator/components/FatalErrorBoundary' +import {OutlineEffect} from '../vendor/OutlineEffect' +import {cache} from '../shot-generator/hooks/use-assets-manager' +import TWEEN from '@tweenjs/tween.js' +import electron from 'electron' +import useSaveImage from './hooks/useSaveImage' +const { ipcRenderer } = electron +import { useDispatch } from 'react-redux' +import FilepathsContext from '../shot-generator/contexts/filepaths' +const { + createUserPresetPathResolver, + createAssetPathResolver +} = require('../shot-generator/services/filepaths') +const getUserPresetPath = createUserPresetPathResolver(electron.remote.app.getPath('userData')) +const defaultSize = 900 +const Effect = ({ shouldRender, dispatch, aspectRatio}) => { + const {gl, size} = useThree() + const [newAssetsLoaded, setLoadedAssets] = useState() + const outlineEffect = new OutlineEffect(gl, { defaultThickness: 0.015 }) + useEffect(() => void outlineEffect.setSize(size.width, size.height), [size]) + const updateAssets = () => {setLoadedAssets({})} + + useEffect(() => { + let assets = Object.values(cache.get()) + for(let i = 0; i < assets.length; i++) { + let asset = assets[i] + if(asset.status !== 'SUCCESS') return + } + setTimeout(() => { + ipcRenderer.send("headless-render:loaded") + }, 0) + }, [newAssetsLoaded, size]) + + + useEffect(() => { + cache.subscribe(updateAssets) + return () => { + cache.unsubscribe(updateAssets) + } + }, []) + + const {saveImage} = useSaveImage(outlineEffect, dispatch) + useEffect(() => { + ipcRenderer.addListener('headless-render:save-shot', saveImage) + return () => { + ipcRenderer.removeListener('headless-render:save-shot', saveImage) + } + }, [saveImage]) + + useFrame(({ gl, scene, camera }, time) => { + if(!shouldRender) return + TWEEN.update() + outlineEffect.render(scene, camera) + }, 1) + + return null +} + +const HeadlessRender = React.memo(({ + aspectRatio, + store, + board, +}) => { + + const [sceneInfo, setSceneInfo] = useState(null) + const dispatch = useDispatch() + const storyboarderFilePath = useSelector(state => state.meta.storyboarderFilePath) + const largeCanvasSize = {width: defaultSize, height: defaultSize} + const [largeCanvasInfo, setLargeCanvasInfo] = useState({width: 0, height: 0}) + const setLargeCanvasData = (camera, scene, gl) => { + setSceneInfo({camera, scene, gl}) + } + + useMemo(() => { + if(!aspectRatio) return + let width = Math.ceil(largeCanvasSize.width) + // assign a target height, based on scene aspect ratio + let height = Math.ceil(width / aspectRatio) + + if (height > largeCanvasSize.height) { + height = Math.ceil(largeCanvasSize.height) + width = Math.ceil(height * aspectRatio) + } + setLargeCanvasInfo({width, height}) + }, [largeCanvasSize.width, largeCanvasSize.height, aspectRatio]) + + + // FIXME + // apparently, storyboarderFilePath is not immediately available, + // so we wait to setup the resolvers until it has a value + // and we also don't render anything until it is + const filepathsState = useMemo( + () => { + if (storyboarderFilePath) { + return { + getAssetPath: createAssetPathResolver(window.__dirname, storyboarderFilePath), + getUserPresetPath + } + } + }, + [window.__dirname, storyboarderFilePath] + ) + + // padding for right side of canvas + return storyboarderFilePath && ( + +
+
+ + + + + + + + +
+
+
+ ) +}) + +const withState = (fn) => (dispatch, getState) => fn(dispatch, getState()) +export default connect( +(state) => ({ + mainViewCamera: state.mainViewCamera, + aspectRatio: state.aspectRatio, + board: state.board, +}), +{ + withState, +}) +(HeadlessRender) \ No newline at end of file diff --git a/src/js/locales/en-US.json b/src/js/locales/en-US.json index ca80057a13..d337bf17db 100644 --- a/src/js/locales/en-US.json +++ b/src/js/locales/en-US.json @@ -688,6 +688,9 @@ "languages": "Languages", "languages-hint": "Select a language, or create your own.", "open-language-editor": "Open Language Editor", + "aspect": "Aspect", + "aspect-hint": "Change board aspect ratio.", + "open-aspect-settings": "Open Aspect Settings", "sign-out": "Sign Out", "sign-out-hint": "You are Signed In to your Storyboarders.com account", "thanks-for-support": "Thanks for supporting Storyboarder!", diff --git a/src/js/locales/ru-RU.json b/src/js/locales/ru-RU.json index 17b8d750fc..c48d61c09d 100644 --- a/src/js/locales/ru-RU.json +++ b/src/js/locales/ru-RU.json @@ -689,7 +689,10 @@ "high-quality-drawing-engine-hint": "Если этот параметр включен, используется более качественный механизм рисования с более продвинутыми кистями. Когда этот параметр отключен, используется (более быстрый) механизм рисования режима эффективности.", "languages": "Языки", "languages-hint": "Выберите язык или добавьте свой собственный", - "open-language-editor": "Открыть редактор языков", + "open-language-editor": "Открыть редактор языков", + "aspect": "Аспект", + "aspect-hint": "Измените аспект своей доски.", + "open-aspect-settings": "Открыть настройки аспектa", "sign-out": "Разлогиниться", "sign-out-hint": "Вы зашли в свой Storyboarders.com аккаунт", "thanks-for-support": "Спасибо за поддержку Storyboarder!", diff --git a/src/js/main.js b/src/js/main.js index 5439c2979d..297cccb1d6 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -40,6 +40,7 @@ const util = require('./utils/index') const {settings:languageSettings} = require('./services/language.config') const autoUpdater = require('./auto-updater') const LanguagePreferencesWindow = require('./windows/language-preferences/main') +const AspectSettingsWindow = require('./windows/aspect-settings/main') //https://github.com/luiseduardobrito/sample-chat-electron @@ -1208,6 +1209,15 @@ ipcMain.on('newBoard', (e, arg)=> { mainWindow.webContents.send('newBoard', arg) }) +ipcMain.on('changeAspectRatio', (e, arg)=> { + mainWindow.webContents.send('changeAspectRatio', arg) +}) + +ipcMain.on('aspectRatioChanged', (e, aspectRatio)=> { + let win = shotGeneratorWindow.getWindow() + win && win.webContents.send('aspectRatioChanged', aspectRatio) +}) + ipcMain.on('deleteBoards', (e, arg)=> { mainWindow.webContents.send('deleteBoards', arg) }) @@ -1547,6 +1557,14 @@ ipcMain.on('openLanguagePreferences', (event) => { //ipcRenderer.send('analyticsEvent', 'Board', 'exportPDF') }) +ipcMain.on('openAspectSettings', (event) => { + let win = AspectSettingsWindow.getWindow() + if (win) { + AspectSettingsWindow.reveal() + } else { + AspectSettingsWindow.createWindow(() => {AspectSettingsWindow.reveal()}) + } +}) ipcMain.on('exportPrintablePdf', (event, sourcePath, fileName) => { mainWindow.webContents.send('exportPrintablePdf', sourcePath, fileName) @@ -1570,7 +1588,9 @@ ipcMain.on('scale-ui-by', (event, value) => mainWindow.webContents.send('scale-ui-by', value)) ipcMain.on('scale-ui-reset', (event, value) => mainWindow.webContents.send('scale-ui-reset', value)) - + ipcMain.on('headless-render:loaded', (event) => { + mainWindow.webContents.send('headless-render:loaded') + }) ipcMain.on('saveShot', (event, data) => mainWindow.webContents.send('saveShot', data)) ipcMain.on('insertShot', diff --git a/src/js/shot-generator/CameraUpdate.js b/src/js/shot-generator/CameraUpdate.js index d9dff3240f..792859a4e3 100644 --- a/src/js/shot-generator/CameraUpdate.js +++ b/src/js/shot-generator/CameraUpdate.js @@ -9,15 +9,18 @@ import { const CameraUpdate = connect( state => ({ activeCamera: getSceneObjects(state)[getActiveCamera(state)], + aspectRatio: state.aspectRatio }), { } )( React.memo(({ - activeCamera + activeCamera, + aspectRatio }) => { const { camera } = useThree() useEffect(() => { let cameraObject = activeCamera + camera.aspect = aspectRatio camera.position.x = cameraObject.x camera.position.y = cameraObject.z camera.position.z = cameraObject.y @@ -29,12 +32,12 @@ const CameraUpdate = connect( camera.userData.type = cameraObject.type camera.userData.locked = cameraObject.locked camera.userData.id = cameraObject.id - }, [activeCamera]) - + }, [activeCamera, aspectRatio]) + useEffect(() => { camera.fov = activeCamera.fov camera.updateProjectionMatrix() - }, [activeCamera.fov]) + }, [activeCamera.fov, aspectRatio]) return null }) ) diff --git a/src/js/shot-generator/components/Toolbar/index.js b/src/js/shot-generator/components/Toolbar/index.js index 140ee3ae5b..4527238188 100644 --- a/src/js/shot-generator/components/Toolbar/index.js +++ b/src/js/shot-generator/components/Toolbar/index.js @@ -95,7 +95,7 @@ const Toolbar = connect( [room] ) - const initializeImage = (id, imagePath = "") => { + const initializeImage = (id, imagePath = 'placeholder') => { initCamera() undoGroupStart() createImage(id, camera.current, room.visible && roomObject3d, imagePath) diff --git a/src/js/shot-generator/hooks/use-save-to-storyboarder.js b/src/js/shot-generator/hooks/use-save-to-storyboarder.js index dbc6dcd78f..2bee527fe5 100644 --- a/src/js/shot-generator/hooks/use-save-to-storyboarder.js +++ b/src/js/shot-generator/hooks/use-save-to-storyboarder.js @@ -157,14 +157,14 @@ const useSaveToStoryboarder = (largeCanvasData, smallCanvasData, aspectRatio, sh () => dispatch( saveCurrentShot(shotRenderer, cameraPlotRenderer, largeCanvasData, smallCanvasData, shotSize, cameraPlotSize, aspectRatio) ), - [] + [shotSize] ) const insertNewShotCb = useCallback( () => dispatch( insertNewShot(shotRenderer, cameraPlotRenderer, largeCanvasData, smallCanvasData, shotSize, cameraPlotSize, aspectRatio) ), - [] + [shotSize] ) return { diff --git a/src/js/utils/ImageService.js b/src/js/utils/ImageService.js new file mode 100644 index 0000000000..df0f1684d5 --- /dev/null +++ b/src/js/utils/ImageService.js @@ -0,0 +1,83 @@ +const { ipcRenderer } = require('electron') +const BoardState = { + Pending: "Pending", + Loaded: "Loaded", + Saved: "Saved", + Cancelled: "Cancelled" +} +class ImageService { + constructor(headlessRender) { + this.headlessRender = headlessRender + this.isImageRerendering = false + this.boards = {} + this.initialBoardIndex = 0 + this.lastIndex = 0 + this.iteration = 1 + ipcRenderer.on('headless-render:loaded', (event) => { + let win = headlessRender.getWindow() + if(this.boards[this.lastIndex].state !== BoardState.Cancelled && this.boards[this.lastIndex].data.layers['shot-generator'] ) { + this.boards[this.lastIndex].state = BoardState.Loaded + win && win.webContents.send('headless-render:save-shot') + } else { + this.continueBoardUpdate() + } + }) + } + + initialize(boards, currentBoard, boardFilename) { + this.isImageRerendering = true + this.boards = boards.map(board => { return {state: BoardState.Pending, data:board} } ) + this.initialBoardIndex = currentBoard + this.lastIndex = currentBoard + this.iteration = 1 + this.boardFilename = boardFilename + } + + continueBoardUpdate(images = {}) { + // If images have plot when action was sent from sg and not the headless-render + if(images.plot) return true + let win = this.headlessRender.getWindow() + if(this.lastIndex === this.initialBoardIndex - this.iteration) this.iteration++ + + let highestIndex = this.initialBoardIndex + this.iteration + let lowestIndex = this.initialBoardIndex - this.iteration + let newIndex + if(this.boards[this.lastIndex]) this.boards[this.lastIndex].state = BoardState.Saved + if(highestIndex !== this.lastIndex) { + newIndex = highestIndex + } else if(lowestIndex !== this.lastIndex) { + newIndex = lowestIndex + } + if(newIndex < this.boards.length && newIndex >= 0) { + let board = this.boards[newIndex] + this.lastIndex = newIndex + if(board.state === BoardState.Cancelled || !board.data.layers['shot-generator']) { + return this.continueBoardUpdate(images) + } + win.webContents.send('headless-render:load-board', { + storyboarderFilePath: this.boardFilename, + board: board.data + }) + } else if(highestIndex >= this.boards.length - 1 && lowestIndex < 0) { + this.isImageRerendering = false + if(this.boards[this.lastIndex] && this.boards[this.lastIndex].state === BoardState.Cancelled) return false + } else { + this.lastIndex = newIndex + return this.continueBoardUpdate(images) + } + return true + } + + cancelBoardUpdate() { + let boards = this.boards + for(let i = 0; i < boards.length; i ++) { + let board = boards[i] + if(board.state !== BoardState.Saved) { + this.boards[i].state = BoardState.Cancelled + } + } + } + +} + +module.exports = ImageService \ No newline at end of file diff --git a/src/js/utils/LocalizationService.js b/src/js/utils/LocalizationService.js new file mode 100644 index 0000000000..8e451cea08 --- /dev/null +++ b/src/js/utils/LocalizationService.js @@ -0,0 +1,59 @@ +const { ipcRenderer, remote } = require('electron') +const i18n = require('../services/i18next.config') +const menu = require('../menu') +class LocalizationService { + constructor(updateMethod) { + this._updateTranslation = () => updateMethod(i18n) + remote.getCurrentWindow().on('focus', () => this._onFocus) + i18n.on('loaded', () => this._onLoaded()) + ipcRenderer.on("languageChanged", (event, lng) => this._onLanguageChanged(event, lng)) + ipcRenderer.on("languageModified", (event, lng) => this._onLanguageModified(event, lng)) + ipcRenderer.on("languageAdded", (event, lng) => this._onLanguageAdded(event, lng)) + ipcRenderer.on("languageRemoved", (event, lng) => this._onLanguageRemoved(event, lng)) + } + + _onFocus () { + menu.setWelcomeMenu(i18n) + } + + _onLoaded () { + let lng = ipcRenderer.sendSync("getCurrentLanguage") + this._updateTranslation() + i18n.changeLanguage(lng, () => { + i18n.on("languageChanged", this.changeLanguage) + this._updateTranslation() + }) + i18n.off('loaded') + } + + _onLanguageChanged (event, lng) { + i18n.off("languageChanged", this.changeLanguage) + i18n.changeLanguage(lng, () => { + i18n.on("languageChanged", this.changeLanguage) + this._updateTranslation() + }) + } + + _onLanguageModified (event, lng) { + i18n.reloadResources(lng).then( () => { this._updateTranslation() } ) + } + + _onLanguageAdded (event, lng) { + i18n.loadLanguages(lng).then(() => { i18n.changeLanguage(lng); }) + } + + _onLanguageRemoved (event, lng) { + i18n.changeLanguage(lng) + } + + changeLanguage (lng) { + if(remote.getCurrentWindow().isFocused()) { + menu.setWelcomeMenu(i18n) + } + this._updateTranslation() + ipcRenderer.send("languageChanged", lng) + } +} + +module.exports = LocalizationService + diff --git a/src/js/window/main-window.js b/src/js/window/main-window.js index ddb39d991f..bf6b6a2ded 100644 --- a/src/js/window/main-window.js +++ b/src/js/window/main-window.js @@ -21,7 +21,7 @@ const { getInitialStateRenderer } = require('electron-redux') const configureStore = require('../shared/store/configureStore') const observeStore = require('../shared/helpers/observeStore') - +//const shotGeneratorWindow = require('../windows/shot-generator/main') const StoryboarderSketchPane = require('./storyboarder-sketch-pane') const { SketchPane } = require('alchemancy') const SketchPaneUtil = require('alchemancy').util @@ -54,6 +54,7 @@ const exporterWeb = require('../exporters/web') const exporterPsd = require('../exporters/psd') const importerPsd = require('../importers/psd') +const headlessRender = require('../windows/headless-render/main') const sceneSettingsView = require('./scene-settings-view') @@ -65,6 +66,8 @@ const AudioFileControlView = require('./audio-file-control-view') const LinkedFileManager = require('./linked-file-manager') +const ImageService = require('../utils/ImageService') + const getIpAddress = require('../utils/getIpAddress') const pkg = require('../../../package.json') @@ -529,7 +532,7 @@ const load = async (event, args) => { // TODO add a cancel button to loading view when a fatal error occurs? } initialize(path.join(app.getPath('userData'), 'storyboarder-settings.json')) - electron.remote.getCurrentWindow().on('resize', resizeScale) + remote.getCurrentWindow().on('resize', resizeScale) } ipcRenderer.on('load', load) @@ -7001,6 +7004,79 @@ ipcRenderer.on('importNotification', () => { } }) +//#region Board images rerender +let imageService = new ImageService(headlessRender) +let aspectThread +function * aspectChange(signal, aspectRatio) { + imageService.cancelBoardUpdate() + boardData.aspectRatio = aspectRatio + fs.writeFileSync(boardFilename, JSON.stringify(boardData, null, 2)) + let size = boardModel.boardFileImageSize(boardData) + size = [Math.round(size[0]), Math.round(size[1])] + storyboarderSketchPane.changePaneSize(size[0], size[1]) + + for (let board of boardData.boards) { + // all the layers, by name, for this board + yield CAF.delay(signal, 1) + let name = 'reference' + if(! board.layers[name]) continue + let filepath = path.join(boardPath, 'images', board.layers[name].url) + let img = yield exporterCommon.getImage(filepath + '?' + cacheKey(filepath)) + if (img) { + let scaledImageData = scaleImage(img, size) + saveDataURLtoFile(scaledImageData, board.layers[name].url) + } else { + log.warn("could not load image for board", board.layers[layerName].url) + } + } + + renderShotGeneratorPanel() + yield updateSketchPaneBoard() + yield renderScene() + resize() + updateThumbnailDisplayFromFile(currentBoard) + //Update reference layer + yield CAF.delay(signal, 1) + ipcRenderer.send('aspectRatioChanged', aspectRatio) + headlessRender.createWindow(() => { + let win = headlessRender.getWindow() + win.webContents.send('headless-render:open') + let selectedBoard = boardData.boards[currentBoard] + boards = Object.values(boardData.boards) + initialBoardIndex = boards.indexOf(selectedBoard) + imageService.initialize(boards, initialBoardIndex, boardFilename) + }, aspectRatio) +} +ipcRenderer.on('changeAspectRatio', async (event, {aspectRatio}) => { + // cancel any in-progress loading + if (cancelTokens.changeAspect && !cancelTokens.changeAspect.signal.aborted) { + log.info(`%ccanceling change of aspect`, 'color:red') + cancelTokens.changeAspect.abort('cancel') + cancelTokens.changeAspect = undefined + } + + // start a new loading process + cancelTokens.changeAspect = new CAF.cancelToken() + try { + aspectThread = CAF(aspectChange) + await aspectThread(cancelTokens.changeAspect.signal, aspectRatio) + } catch (err) { + log.warn(err) + } + +}) + +const scaleImage = (image, boardSize) => { + let context = createSizedContext(boardSize) + let canvas = context.canvas + let scaleX = canvas.width / image.width + let scaleY = canvas.height / image.height + let x = (canvas.width / 2) - (image.width / 2) * scaleX + let y = (canvas.height / 2) - (image.height / 2) * scaleY + context.drawImage(image, x, y, image.width * scaleX, image.height * scaleY) + return canvas.toDataURL() +} +////#endregion ipcRenderer.on('importWorksheets', (event, args) => { if (!importWindow) { importWindow = new remote.BrowserWindow({ @@ -7180,14 +7256,16 @@ const saveToBoardFromShotGenerator = async ({ uid, data, images }) => { saveDataURLtoFile(context.canvas.toDataURL(), board.layers['shot-generator'].url) // save camera-plot (re-use context) - let plotImage = await exporterCommon.getImage(images.plot) - context.canvas.width = 900 - context.canvas.height = 900 - context.drawImage(plotImage, 0, 0) - saveDataURLtoFile( - context.canvas.toDataURL(), - boardModel.boardFilenameForCameraPlot(board) - ) + if(images.plot) { + let plotImage = await exporterCommon.getImage(images.plot) + context.canvas.width = 900 + context.canvas.height = 900 + context.drawImage(plotImage, 0, 0) + saveDataURLtoFile( + context.canvas.toDataURL(), + boardModel.boardFilenameForCameraPlot(board) + ) + } // save shot-generator-thumbnail.jpg // thumbnail size @@ -7221,6 +7299,9 @@ const saveToBoardFromShotGenerator = async ({ uid, data, images }) => { renderShotGeneratorPanel() } ipcRenderer.on('saveShot', async (event, { uid, data, images }) => { + let needsSaving = imageService.continueBoardUpdate(images) + if(!needsSaving) return + storeUndoStateForScene(true) await saveToBoardFromShotGenerator({ uid, data, images }) storeUndoStateForScene() @@ -7253,12 +7334,26 @@ ipcRenderer.on('storyboarder:get-boards', event => { hasSg: board.sg ? true : false })) }) + let win = headlessRender.getWindow() + win && win.webContents.send('headless-render:get-boards', { + boards: boardData.boards.map(board => ({ + uid: board.uid, + shot: board.shot, + thumbnail: boardModel.boardFilenameForThumbnail(board), + hasSg: board.sg ? true : false + })) + }) }) ipcRenderer.on('storyboarder:get-board', (event, uid) => { ipcRenderer.send( 'shot-generator:get-board', boardData.boards.find(board => board.uid === uid) ) + let win = headlessRender.getWindow() + win && win.webContents.send( + 'headless-render:get-board', + boardData.boards.find(board => board.uid === uid) + ) }) ipcRenderer.on('storyboarder:get-storyboarder-file-data', (event, uid) => { ipcRenderer.send( @@ -7271,6 +7366,17 @@ ipcRenderer.on('storyboarder:get-storyboarder-file-data', (event, uid) => { } } ) + let win = headlessRender.getWindow() + win && win.webContents.send( + 'headless-render:get-storyboarder-file-data', + { + storyboarderFilePath: boardFilename, + boardData: { + version: boardData.version, + aspectRatio: boardData.aspectRatio + } + } + ) }) ipcRenderer.on('storyboarder:get-state', event => { let board = boardData.boards[currentBoard] @@ -7280,6 +7386,13 @@ ipcRenderer.on('storyboarder:get-state', event => { board } ) + let win = headlessRender.getWindow() + win && win.webContents.send( + 'headless-render:get-state', + { + board + } + ) }) const logToView = opt => ipcRenderer.send('log', opt) diff --git a/src/js/window/storyboarder-sketch-pane.js b/src/js/window/storyboarder-sketch-pane.js index 414ccb67bf..657efb1a3b 100644 --- a/src/js/window/storyboarder-sketch-pane.js +++ b/src/js/window/storyboarder-sketch-pane.js @@ -441,6 +441,64 @@ class StoryboarderSketchPane extends EventEmitter { return true } + changePaneSize(width, height) { + width = Math.ceil(width) + height = Math.ceil(height) + let sketchPane = this.sketchPane + this.canvasSize = [width, height] + sketchPane.width = width + sketchPane.height = height + + let layers = sketchPane.layers + layers.width = width + layers.height = height + + for(let i = 0; i < layers.length; i++) { + let layer = layers[i] + layer.width = width + layer.height = height + layer.container.height = height + layer.container.width = width + + layer.sprite._texture.resize(width, height) + layer.sprite.width = width + layer.sprite.height = height + } + + sketchPane.eraseMask.height = height + sketchPane.eraseMask.width = width + sketchPane.eraseMask.texture = PIXI.RenderTexture.create(width, height) + sketchPane.strokeSprite.texture = PIXI.RenderTexture.create(width, height) + + let paneChildren = sketchPane.sketchPaneContainer.children + for(let i = 0; i < paneChildren.length; i++) { + let child = paneChildren[i] + if(child.name === "layerMask") { + child.clear() + child.beginFill(0x0, 1) + .drawRect(0, 0, width, height) + .endFill() + } + } + + sketchPane.strokeSprite.width = width + sketchPane.strokeSprite.height = height + + let layersChildren = sketchPane.layersContainer.children + for(let i = 0; i < layersChildren.length; i++) { + let child = layersChildren[i] + if(child.name === "background") { + child.clear() + child.beginFill(0xffffff) + .drawRect(0, 0, width, height) + .endFill() + } else if(child.name.includes("container")) { + child.width = width + child.height = height + } + } + } + // TODO do we need this? replaceLayer (index, image) { this.emit('addToUndoStack') diff --git a/src/js/windows/aspect-settings/main.js b/src/js/windows/aspect-settings/main.js new file mode 100644 index 0000000000..a8f5ea1caf --- /dev/null +++ b/src/js/windows/aspect-settings/main.js @@ -0,0 +1,115 @@ +const {BrowserWindow} = electron = require('electron') +const path = require('path') +const url = require('url') + +process.env['ELECTRON_DISABLE_SECURITY_WARNINGS'] = true +//const { default: installExtension, REACT_DEVELOPER_TOOLS, REACT_PERF, REDUX_DEVTOOLS } = require('electron-devtools-installer') +const installExtensions = async () => { + const installer = require('electron-devtools-installer'); + const forceDownload = !!process.env.UPGRADE_EXTENSIONS; + const extensions = ['REACT_DEVELOPER_TOOLS', 'REDUX_DEVTOOLS']; + + return Promise.all( + extensions.map(name => installer.default(installer[name], forceDownload)) + ).catch(console.log); +}; + +// Removes any extension from the production version +const removeExtensions = () => { + const installed = BrowserWindow.getDevToolsExtensions() + + for (let extension of Object.keys(installed)) { + BrowserWindow.removeDevToolsExtension(extension) + } +} + +let win +let loaded = false + +let memento = { + x: undefined, + y: undefined, + width: 1505, + height: 1080 +} + +const reveal = () => { + win.show() + win.focus() + //onComplete(win) + } + +const createWindow = async ( onComplete) => { + if (process.env.NODE_ENV === 'development') { + await installExtensions() + } else { + removeExtensions() + } + + if (win) { + //reveal(onComplete) + return + } + let { x, y } = memento + win = new BrowserWindow({ + x, + y, + width: 640, + height: 640, + minWidth: 600, + minHeight: 600, + maxHeight: 700, + maxWidth: 700, + + show: false, + center: true, + frame: true, + + backgroundColor: '#333333', + titleBarStyle: 'hiddenInset', + title: "Aspect settings", + acceptFirstMouse: true, + simpleFullscreen: true, + webPreferences: { + nodeIntegration: true, + plugins: true, + webSecurity: false, + allowRunningInsecureContent: true, + experimentalFeatures: true, + backgroundThrottling: true, + backgroundThrottling: true + }, + }) + + win.on('resize', () => memento = win.getBounds()) + win.on('move', () => memento = win.getBounds()) + win.webContents.on('will-prevent-unload', event => { + event.preventDefault() + win.hide() + }) + + win.once('closed', () => { + win = null + }) + win.loadURL(url.format({ + pathname: path.join(__dirname, '..', '..', '..', 'aspect-settings.html'), + protocol: 'file:', + slashes: true + })) + + // use this to wait until the window has completely loaded + //ipcMain.on('shot-generator:window:loaded', () => { }) + + // use this to show sooner + win.once('ready-to-show', () => { + onComplete() + loaded = true + }) + } + +module.exports = { + createWindow, + getWindow: () => win, + reveal, + isLoaded: () => loaded +} \ No newline at end of file diff --git a/src/js/windows/aspect-settings/window.js b/src/js/windows/aspect-settings/window.js new file mode 100644 index 0000000000..bad04efef3 --- /dev/null +++ b/src/js/windows/aspect-settings/window.js @@ -0,0 +1,29 @@ +const { ipcRenderer } = require('electron') +const LocalizationService = require('../../utils/LocalizationService') +translateHtml = (elementName, translation) => { + let elem = document.querySelector(elementName) + if(!elem) return + let array = translation.split("\n") + elem.innerHTML = array.map(text => `${text}
`).join("") +} + +const updateHTMLText = (i18n) => { + translateHtml("#aspect-title", i18n.t("new-window.aspect-title")) + translateHtml("#aspect-ultrawide", i18n.t("new-window.aspect-ultrawide")) + translateHtml("#aspect-doublewide", i18n.t("new-window.aspect-doublewide")) + translateHtml("#aspect-wide", i18n.t("new-window.aspect-wide")) + translateHtml("#aspect-hd", i18n.t("new-window.aspect-hd")) + translateHtml("#aspect-vertical-hd", i18n.t("new-window.aspect-vertical-hd")) + translateHtml("#aspect-square", i18n.t("new-window.aspect-square")) + translateHtml("#aspect-old", i18n.t("new-window.aspect-old")) + translateHtml("#aspect-description", i18n.t("new-window.aspect-description")) +} + +let localizationService = new LocalizationService(updateHTMLText) + +document.querySelectorAll('.example').forEach(el => { + el.addEventListener('click', event => { + ipcRenderer.send('changeAspectRatio', { aspectRatio: el.dataset.aspectRatio }) + event.preventDefault() + }) +}) \ No newline at end of file diff --git a/src/js/windows/headless-render/main.js b/src/js/windows/headless-render/main.js new file mode 100644 index 0000000000..8203f4fbba --- /dev/null +++ b/src/js/windows/headless-render/main.js @@ -0,0 +1,93 @@ +const { BrowserWindow, ipcMain, app, dialog } = electron = require('electron').remote +const isDev = require('electron-is-dev') + +const path = require('path') +const url = require('url') + +process.env['ELECTRON_DISABLE_SECURITY_WARNINGS'] = true + +let win +let loaded = false + +let memento = { + x: undefined, + y: undefined, + width: 1505, + height: 1080 +} + +const reveal = () => { + win.show() + win.focus() + //win.webContents.openDevTools() + //onComplete(win) + } + +const createWindow = async ( onComplete, aspectRatio) => { + if (win) { + onComplete() + return + } + let { x, y, width, height } = memento + win = new BrowserWindow({ + // minWidth: (500 * aspectRatio), + //minHeight: 800, + + // maxWidth: (300 * aspectRatio), + x, + y, + width, + height, + + show: false, + center: true, + frame: true, + + backgroundColor: '#333333', + titleBarStyle: 'hiddenInset', + title: "Headless render", + acceptFirstMouse: true, + simpleFullscreen: true, + webPreferences: { + nodeIntegration: true, + plugins: true, + webSecurity: false, + allowRunningInsecureContent: true, + experimentalFeatures: true, + backgroundThrottling: true, + enableRemoteModule: true + }, + }) + + win.on('resize', () => memento = win.getBounds()) + win.on('move', () => memento = win.getBounds()) + win.webContents.on('will-prevent-unload', event => { + event.preventDefault() + win.hide() + }) + + win.once('closed', () => { + win = null + }) + win.loadURL(url.format({ + pathname: path.join(window.__dirname, 'headless-render.html'), + protocol: 'file:', + slashes: true + })) + + // use this to wait until the window has completely loaded + //ipcMain.on('shot-generator:window:loaded', () => { }) + + // use this to show sooner + win.once('ready-to-show', () => { + onComplete() + loaded = true + }) + } + +module.exports = { + createWindow, + getWindow: () => win, + reveal, + isLoaded: () => loaded +} \ No newline at end of file diff --git a/src/js/windows/headless-render/service.js b/src/js/windows/headless-render/service.js new file mode 100644 index 0000000000..f0ec036971 --- /dev/null +++ b/src/js/windows/headless-render/service.js @@ -0,0 +1,43 @@ +const { ipcRenderer } = electron = require('electron') + +const service = {} + +service.getStoryboarderFileData = () => + new Promise(resolve => { + ipcRenderer.once('headless-render:get-storyboarder-file-data', (event, data) => { + resolve(data) + }) + ipcRenderer.send('storyboarder:get-storyboarder-file-data') + }) + +service.getStoryboarderState = () => + new Promise(resolve => { + ipcRenderer.once('headless-render:get-state', (event, data) => { + resolve(data) + }) + ipcRenderer.send('storyboarder:get-state') + }) + +service.getBoards = () => + new Promise(resolve => { + ipcRenderer.once('headless-render:get-boards', (event, { boards }) => { + resolve(boards) + }) + ipcRenderer.send('storyboarder:get-boards') + }) + +service.getBoard = uid => + new Promise(resolve => { + ipcRenderer.once('headless-render:get-board', (event, board) => { + resolve(board) + }) + ipcRenderer.send('storyboarder:get-board', uid) + }) + +service.loadBoardByUid = async uid => { + // ask main > Shot Generator > to call loadBoardByUid + ipcRenderer.send('headless-render:loadBoardByUid', uid) +} + + +module.exports = service diff --git a/src/js/windows/headless-render/window.js b/src/js/windows/headless-render/window.js new file mode 100644 index 0000000000..cb06877dca --- /dev/null +++ b/src/js/windows/headless-render/window.js @@ -0,0 +1,160 @@ +const ReactDOM = require('react-dom') +const React = require('react') +const { ipcRenderer, shell } = electron = require('electron') +const { Provider, batch } = require('react-redux') +const { createStore, applyMiddleware, compose } = require('redux') +const thunkMiddleware = require('redux-thunk').default +const { reducer } = require('../../shared/reducers/shot-generator') +const presetsStorage = require('../../shared/store/presetsStorage') +const { initialState } = require('../../shared/reducers/shot-generator') +const poses = require('../../shared/reducers/shot-generator-presets/poses.json') +const HeadlessRender = require('../../headless-render').default +const service = require('./service') +const {loadAsset, cleanUpCache} = require("../../shot-generator/hooks/use-assets-manager") +const ModelLoader = require("./../../services/model-loader") +const {getFilePathForImages} = require("./../../shot-generator/helpers/get-filepath-for-images") +const { + setBoard, + loadScene, + resetScene, +} = require('../../shared/reducers/shot-generator') +require("../../shared/helpers/monkeyPatchGrayscale") +const actionSanitizer = action => ( + action.type === 'ATTACHMENTS_SUCCESS' && action.payload ? + { ...action, payload: { ...action.payload, value: '<>' } } : action +) +const stateSanitizer = state => state.attachments ? { ...state, attachments: '<>' } : state +const reduxDevtoolsExtensionOptions = { + actionSanitizer, + stateSanitizer, + trace: true, +} + +const composeEnhancers = ( + window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ && + window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__(reduxDevtoolsExtensionOptions) + ) || compose + +const configureStore = function configureStore (preloadedState) { + const store = createStore( + reducer, + preloadedState, + composeEnhancers( + applyMiddleware(thunkMiddleware) + ) + ) + return store +} + +const store = configureStore({ + ...initialState, + presets: { + ...initialState.presets, + scenes: { + ...initialState.presets.scenes, + ...presetsStorage.loadScenePresets().scenes + }, + characters: { + ...initialState.presets.characters, + ...presetsStorage.loadCharacterPresets().characters + }, + poses: { + ...initialState.presets.poses, + ...poses, + ...presetsStorage.loadPosePresets().poses + }, + handPoses: { + ...initialState.presets.handPoses, + ...presetsStorage.loadHandPosePresets().handPoses + } + }, +}) + +ipcRenderer.on("headless-render:open", async (event) => { + const { storyboarderFilePath, boardData } = await service.getStoryboarderFileData() + const { board } = await service.getStoryboarderState() + let aspectRatio = parseFloat(boardData.aspectRatio) + + let action = { + type: 'SET_META_STORYBOARDER_FILE_PATH', + payload: storyboarderFilePath + } + store.dispatch(action) + action = { + type: 'SET_ASPECT_RATIO', + payload: aspectRatio + } + store.dispatch(action) + await loadBoard(board, storyboarderFilePath) + renderDom() +}) + +ipcRenderer.on("headless-render:load-board", async (event, boardData) => { + const { storyboarderFilePath, board } = boardData + await loadBoard(board, storyboarderFilePath) +}) + +const loadBoard = async (board, storyboarderFilePath) => { + let shot = board.sg + let action = setBoard(board) + store.dispatch(action) + + if (shot) { + action = loadScene(shot.data) + store.dispatch(action) + } else { + action = resetScene() + store.dispatch(action) + } + + + if (!board.sg) { + return false + } + + const {sceneObjects, world} = board.sg.data + + await Object.values(sceneObjects) + // has a value for model + .filter(o => o.model != null) + // is not a box + .filter(o => !(o.type === 'object' && o.model === 'box')) + // what's the filepath? + .map((object) => ModelLoader.getFilepathForModel(object, { storyboarderFilePath })) + // request the file + .map(loadAsset) + + if (world.environment.file) { + await loadAsset( + ModelLoader.getFilepathForModel({ + model: world.environment.file, + type: 'environment' + }, { storyboarderFilePath }) + ) + } + + const paths = Object.values(sceneObjects) + .filter(o => o.volumeImageAttachmentIds && o.volumeImageAttachmentIds.length > 0) + .map((object) => getFilePathForImages(object, storyboarderFilePath)) + + for(let i = 0; i < paths.length; i++) { + if(!Array.isArray(paths[i])) { + await loadAsset(paths[i]) + } else { + for(let j = 0; j < paths[i].length; j++) { + await loadAsset(paths[i][j]) + } + } + } +} + +const renderDom = () => { + ReactDOM.render( + (store && + + ), + document.getElementById('main') + ) +} +renderDom() + diff --git a/src/js/windows/language-preferences/main.js b/src/js/windows/language-preferences/main.js index 9bab375812..4228eabed6 100644 --- a/src/js/windows/language-preferences/main.js +++ b/src/js/windows/language-preferences/main.js @@ -18,7 +18,6 @@ let memento = { const reveal = () => { win.show() win.focus() - win.webContents.openDevTools() //onComplete(win) } diff --git a/src/js/windows/preferences/editor.js b/src/js/windows/preferences/editor.js index 4117327be9..26bc159a56 100644 --- a/src/js/windows/preferences/editor.js +++ b/src/js/windows/preferences/editor.js @@ -103,6 +103,9 @@ const updateHTML = () => { translateHtml("#languages", "preferences.languages") translateHtml("#languages-hint", "preferences.languages-hint") translateHtml("#open-language-editor", "preferences.open-language-editor") + translateHtml("#aspect", "preferences.aspect") + translateHtml("#aspect-hint", "preferences.aspect-hint") + translateHtml("#open-aspect-settings", "preferences.open-aspect-settings") translateHtml("#sign-out", "preferences.sign-out") translateHtml("#sign-out-hint", "preferences.sign-out-hint") translateHtml("#thanks-for-support", "preferences.thanks-for-support") @@ -305,6 +308,10 @@ const openLanguageEditor = () => { ipcRenderer.send('openLanguagePreferences') } +const openAspectSettings = () => { + ipcRenderer.send('openAspectSettings') +} + let selectedOption const selectLanguage = (language) => { @@ -365,7 +372,8 @@ const init = () => { initializeLanguageList() let languageEditor = document.getElementsByClassName('open-language-editor')[0].children[0] languageEditor.onclick = openLanguageEditor - + let aspectSettings = document.getElementById('open-aspect-settings') + aspectSettings.onclick = openAspectSettings // bind for (let el of inputs) { el.addEventListener('change', onChange.bind(this, el.name)) diff --git a/src/js/windows/shot-explorer/window.js b/src/js/windows/shot-explorer/window.js index bd2ed23a3a..23b06b5473 100644 --- a/src/js/windows/shot-explorer/window.js +++ b/src/js/windows/shot-explorer/window.js @@ -107,19 +107,27 @@ ipcRenderer.on('shot-explorer:show', (event) => { showShotExplorer() }) -ipcRenderer.on("shot-generator:open:shot-explorer", async (event) => { - const { storyboarderFilePath, boardData } = await service.getStoryboarderFileData() - const { board } = await service.getStoryboarderState() - let aspectRatio = parseFloat(boardData.aspectRatio) - - canvasHeight = defaultHeight * 0.45 +const updateWindow = (aspectRatio) => { let scaledWidth = Math.ceil(canvasHeight * aspectRatio) scaledWidth = minimumWidth > scaledWidth ? minimumWidth : scaledWidth let win = electron.remote.getCurrentWindow() - win.setSize(scaledWidth, defaultHeight) win.setMinimumSize(scaledWidth, defaultHeight) win.setMaximumSize(scaledWidth, 100000) + win.setSize(scaledWidth, defaultHeight) win.center() +} + +ipcRenderer.on("shot-explorer:change-aspect", (event, aspectRatio) => { + updateWindow(aspectRatio) +}) + +ipcRenderer.on("shot-generator:open:shot-explorer", async (event) => { + const { storyboarderFilePath, boardData } = await service.getStoryboarderFileData() + const { board } = await service.getStoryboarderState() + let aspectRatio = parseFloat(boardData.aspectRatio) + + canvasHeight = defaultHeight * 0.45 + updateWindow(aspectRatio) let action = { type: 'SET_META_STORYBOARDER_FILE_PATH', payload: storyboarderFilePath diff --git a/src/js/windows/shot-generator/window.js b/src/js/windows/shot-generator/window.js index babaef4aef..320fdb0828 100644 --- a/src/js/windows/shot-generator/window.js +++ b/src/js/windows/shot-generator/window.js @@ -259,6 +259,14 @@ ipcRenderer.on('shot-generator:edit:redo', () => { store.dispatch( ActionCreators.redo() ) }) +ipcRenderer.on('aspectRatioChanged', (event, aspectRatio) => { + store.dispatch({ + type: 'SET_ASPECT_RATIO', + payload: aspectRatio + }) + shotExplorer.getWindow().webContents.send('shot-explorer:change-aspect', aspectRatio) +}) + ipcRenderer.on('shot-generator:show:shot-explorer', () => { if(!shotExplorer.isLoaded()) { diff --git a/src/preferences.html b/src/preferences.html index 20b37c315b..e79ff32b8a 100644 --- a/src/preferences.html +++ b/src/preferences.html @@ -327,6 +327,18 @@

Languages

+
+

Aspect

+
+ Change board aspect ratio +
+
+ +
+
+