From 5f96ca82ea6fe5975619f88eb82686fd4b4176a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Preu=C3=9F?= Date: Sun, 6 Aug 2023 17:22:05 +0200 Subject: [PATCH] Add encoder settings Add encoder cancel button --- electron/main/index.ts | 9 +- electron/preload/index.ts | 3 + electron/rerun-manager/encoder.ts | 126 +++++++---- electron/rerun-manager/file-uploader.ts | 10 +- electron/rerun-manager/settings-repository.ts | 47 ++-- package.json | 3 +- shared/schema.d.ts | 18 +- src/App.vue | 52 ++++- src/components/AppEncoderOptions.vue | 201 ++++++++++++++++++ src/style.css | 2 +- 10 files changed, 392 insertions(+), 79 deletions(-) create mode 100644 src/components/AppEncoderOptions.vue diff --git a/electron/main/index.ts b/electron/main/index.ts index 6b7d968..157126c 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -53,6 +53,7 @@ require('update-electron-app')({ // process.env['ELECTRON_DISABLE_SECURITY_WARNINGS'] = 'true' let win: BrowserWindow | null = null +let encoder: Encoder | null = null // Here, you can also use other preload const preload = join(__dirname, '../preload/index.js') const url = process.env.VITE_DEV_SERVER_URL @@ -79,11 +80,11 @@ async function createWindow(settings: Settings) { }) if (process.env.VITE_DEV_SERVER_URL) { // electron-vite-vue#298 - win.loadURL(url) + await win.loadURL(url) // Open devTool if the app is not packaged // win.webContents.openDevTools() } else { - win.loadFile(indexHtml) + await win.loadFile(indexHtml) } // Test actively push message to the Electron-Renderer @@ -93,6 +94,7 @@ async function createWindow(settings: Settings) { // Make all links open with the browser, not with the application win.webContents.setWindowOpenHandler(({url}) => { + console.log('setWindowOpenHandler', url) if (url.startsWith('https:')) shell.openExternal(url) return {action: 'deny'} }) @@ -159,7 +161,7 @@ ipcMain.handle('quit', async () => app.quit()) ipcMain.handle('minimize', async () => win.minimize()) ipcMain.handle('encode', async (event: IpcMainInvokeEvent, ...args: any[]) => { const arg = args[0] as { id: string, input: string, options: EncoderOptions }; - const encoder = new Encoder(arg.id, arg.input, arg.options, { + encoder = new Encoder(arg.id, arg.input, arg.options, { onStart: (id) => event.sender.send('encode-start', id), onProgress: (id, progress) => event.sender.send('encode-progress', id, progress), onUploadProgress: (id, progress) => event.sender.send('encode-upload-progress', id, progress), @@ -168,6 +170,7 @@ ipcMain.handle('encode', async (event: IpcMainInvokeEvent, ...args: any[]) => { }, settingsRepository); return encoder.encode(); }) +ipcMain.handle('cancel-encode', async () => encoder.cancel()); ipcMain.handle('commitSettings', async (event: IpcMainInvokeEvent, ...args: any[]) => settingsRepository.commitSettings(args[0])) settingsRepository.watch((settings: Settings) => emit('settings', settings)); diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 51dcf8a..85a726d 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -161,6 +161,9 @@ window.api = { logout: () => { return ipcRenderer.invoke('logout') }, + cancelEncode: () => { + return ipcRenderer.invoke('cancel-encode') +}, encode: (id: string, input: string, options: EncoderOptions) => { return ipcRenderer.invoke('encode', {id, input, options}) }, diff --git a/electron/rerun-manager/encoder.ts b/electron/rerun-manager/encoder.ts index b0b2353..7a7b7ce 100644 --- a/electron/rerun-manager/encoder.ts +++ b/electron/rerun-manager/encoder.ts @@ -1,60 +1,72 @@ -import {EncoderListeners, EncoderOptions, Video} from "../../shared/schema"; -import * as fs from "fs"; -import axios, {AxiosInstance} from "axios"; -import {SettingsRepository} from "./settings-repository"; -import * as path from "path"; -import {path as ffmpegPath} from "@ffmpeg-installer/ffmpeg"; -import {upload} from "./file-uploader"; +import { EncoderListeners, EncoderOptions, Video } from '../../shared/schema' +import * as fs from 'fs' +import axios, { AxiosInstance } from 'axios' +import { SettingsRepository } from './settings-repository' +import * as path from 'path' +import { path as ffmpegPath } from '@ffmpeg-installer/ffmpeg' +import { upload } from './file-uploader' + +console.log('ffmpegPath', ffmpegPath) +const ffmpeg = require('fluent-ffmpeg') +ffmpeg.setFfmpegPath(ffmpegPath) + +let ffmpegCommand: any = null + +export interface Signal { + aborted: boolean +} export class Encoder { - private readonly id: string; - private readonly input: string; - private readonly output: string; - private readonly options: EncoderOptions; - private readonly listeners: EncoderListeners; - private readonly settingsRepository: SettingsRepository; - private api: AxiosInstance; - private s3: AxiosInstance; + private readonly id: string + private readonly input: string + private readonly output: string + private readonly options: EncoderOptions + private readonly listeners: EncoderListeners + private readonly settingsRepository: SettingsRepository + private api: AxiosInstance + private s3: AxiosInstance + private signal: Signal = {aborted: false} constructor( id: string, input: string, options: EncoderOptions, listeners: EncoderListeners, - settingsRepository: SettingsRepository + settingsRepository: SettingsRepository, ) { - this.id = id; - this.input = input; - this.output = this.getOutputPath(input, 'flv'); - this.options = options; - this.listeners = listeners; - this.settingsRepository = settingsRepository; + this.id = id + this.input = input + this.output = this.getOutputPath(input, 'flv') + this.options = options + this.listeners = listeners + this.settingsRepository = settingsRepository - const settings = settingsRepository.getSettings(); + const settings = settingsRepository.getSettings() this.api = axios.create({ baseURL: settings.endpoint, headers: { - Authorization: `${settings.credentials.token_type} ${settings.credentials.access_token}` - } + Authorization: `${settings.credentials.token_type} ${settings.credentials.access_token}`, + }, }) - this.s3 = axios.create({}); + this.s3 = axios.create({}) } encode(): void { + this.signal.aborted = false this.listeners.onStart(this.id) if (this.requireEncoding()) { - const ffmpegPath = require('@ffmpeg-installer/ffmpeg').path - console.log('ffmpegPath', ffmpegPath) - const ffmpeg = require('fluent-ffmpeg') - ffmpeg.setFfmpegPath(ffmpegPath) - let totalTime = 0; + let totalTime = 0 + const outputOptions = this.getOutputOptions() - ffmpeg(this.input) + console.log('Using the following output options:', outputOptions) + + ffmpegCommand = ffmpeg(this.input) + ffmpegCommand .output(this.output) - .outputOptions(this.getOutputOptions()) + .outputOptions(outputOptions) .on('start', () => console.log('start')) .on('codecData', data => totalTime = parseInt(data.duration.replace(/:/g, ''))) .on('progress', progress => { @@ -75,23 +87,32 @@ export class Encoder { } private getOutputOptions() { + const output = this.settingsRepository.getSettings().output + return [ - '-c:v libx264', - '-preset veryfast', - '-crf 23', - '-maxrate 6000k', - '-bufsize 6000k', - '-c:a aac', - '-b:a 128k', + `-c:v ${output.video.encoder}`, // libx264 Use NVIDIA NVENC for hardware-accelerated encoding + `-profile:v ${output.profile}`, // Use the "main" profile for H264 (compatible with most browsers) + '-x264opts keyint=60:no-scenecut', // Set the key frame interval to 60 seconds (same as the original video) + `-preset ${output.preset}`, // Use the "fast" preset for faster transcoding + `-crf ${output.crf}`, // Adjust the Constant Rate Factor for the desired quality (lower values mean higher quality, 18-28 is usually a good range) + '-sws_flags bilinear', // Use bilinear scaling algorithm for better image quality + `-maxrate ${output.video.bitrate}k`, + `-bufsize ${output.video.bitrate}k`, + `-c:a ${output.audio.encoder}`, + `-b:a ${output.audio.bitrate}k`, '-ac 2', '-f flv', '-movflags +faststart', - '-y' + '-y', ] } private async requestUpload(filename: string): Promise { - const user = this.settingsRepository.getSettings().credentials.user; + if (this.signal.aborted) { + console.log('upload aborted, skipping upload request') + return + } + const user = this.settingsRepository.getSettings().credentials.user const response = await this.api.post(`channels/${user.id}/videos`, { title: this.options.title, size: fs.statSync(filename).size, @@ -114,10 +135,11 @@ export class Encoder { console.log('confirm', response.data) - return response.data as Video; + return response.data as Video } private async cancelUpload(video: Video): Promise { + console.log('cancel upload', video.id) await this.api.delete(`videos/${video.id}/cancel`) } @@ -126,12 +148,18 @@ export class Encoder { return } + if (this.signal.aborted) { + console.log('upload aborted, skipping upload handle') + this.listeners.onError(this.id, `Upload Error: Upload aborted`) + return + } + try { await upload(video.upload_url, filename, (progress: number) => { this.listeners.onUploadProgress(this.id, progress) - }) + }, this.signal) - this.settingsRepository.reloadUser(); + this.settingsRepository.reloadUser() this.listeners.onUploadComplete(this.id, await this.confirmUpload(video)) } catch (error) { @@ -162,4 +190,12 @@ export class Encoder { private requireEncoding() { return path.parse(this.input).ext !== '.flv' } + + cancel() { + console.log('cancel rerun encode') + this.signal.aborted = true + if (ffmpegCommand) { + ffmpegCommand.kill() + } + } } \ No newline at end of file diff --git a/electron/rerun-manager/file-uploader.ts b/electron/rerun-manager/file-uploader.ts index 337ac3a..8d89535 100644 --- a/electron/rerun-manager/file-uploader.ts +++ b/electron/rerun-manager/file-uploader.ts @@ -1,8 +1,10 @@ +import { Signal } from './encoder' + const fs = require('fs'); const https = require('https'); const {promisify} = require('util'); -export async function upload(url, filename, onProgress): Promise { +export async function upload(url, filename, onProgress, signal: Signal): Promise { const fileStream = fs.createReadStream(filename); const fileStats = await promisify(fs.stat)(filename); @@ -29,6 +31,12 @@ export async function upload(url, filename, onProgress): Promise { let uploadedBytes = 0; fileStream.on('data', (chunk) => { + if (signal.aborted) { + console.log('upload aborted, skipping upload stream') + req.destroy(); + fileStream.destroy(); + return; + } uploadedBytes += chunk.length; onProgress(Math.round((uploadedBytes / fileStats.size) * 100)); }); diff --git a/electron/rerun-manager/settings-repository.ts b/electron/rerun-manager/settings-repository.ts index 72a3cbe..6975751 100644 --- a/electron/rerun-manager/settings-repository.ts +++ b/electron/rerun-manager/settings-repository.ts @@ -1,20 +1,34 @@ -import defu from 'defu' -import {platform} from 'node:process' +import { defu } from 'defu' +import { platform } from 'node:process' import * as fs from 'fs' -import {Credentials, Settings} from "../../shared/schema"; -import {resolveUser} from "../main/helpers"; +import { Credentials, Settings } from '../../shared/schema' +import { resolveUser } from '../main/helpers' const defaults: Settings = { version: '1.0.1', credentials: null, endpoint: 'https://api.rerunmanager.com/v1/', + deleteOnComplete: true, + output: { + video: { + encoder: 'libx264', + bitrate: 4500, + }, + audio: { + encoder: 'aac', + bitrate: 128, + }, + preset: 'fast', + profile: 'high', + crf: 23, + }, } export class SettingsRepository { private settings: Settings | null private readonly path: string - private readonly listeners: Array<(settings: Settings) => void>; - private readonly directory: string; + private readonly listeners: Array<(settings: Settings) => void> + private readonly directory: string constructor() { this.listeners = [] @@ -38,14 +52,14 @@ export class SettingsRepository { const data = await fs.promises.readFile(this.path, {encoding: 'utf-8'}) if (data) { - const settings = JSON.parse(data) as Settings; + const settings = JSON.parse(data) as Settings if (settings.version !== defaults.version) { - console.log('Settings version mismatch, resetting to defaults'); + console.log('Settings version mismatch, resetting to defaults') this.settings = defaults } else { - console.log('Settings version match, merge with defaults'); - this.settings = defu.defu(settings, defaults) + console.log('Settings version match, merge with defaults') + this.settings = defu(settings, defaults) } } else { console.log('Settings file empty, resetting to defaults') @@ -59,9 +73,9 @@ export class SettingsRepository { // check if settings.credentials.expires_at is in the past // if so, set settings.credentials to null if (this.isExpired()) { - console.log('Credentials expired!'); + console.log('Credentials expired!') } else { - this.reloadUser(); + this.reloadUser() } await this.save() @@ -72,7 +86,7 @@ export class SettingsRepository { async save() { // call all listeners with the current settings this.listeners.forEach( - (listener: (settings: Settings) => void) => listener(this.settings) + (listener: (settings: Settings) => void) => listener(this.settings), ) const pretty = JSON.stringify(this.settings, null, 2) @@ -89,7 +103,7 @@ export class SettingsRepository { return process.env.HOME + '/Library/Preferences' + path } - return process.env.HOME + "/.local/share" + path + return process.env.HOME + '/.local/share' + path } getSettings() { @@ -106,6 +120,7 @@ export class SettingsRepository { } commitSettings(settings: Settings) { + console.log('Committing settings', settings) this.settings = defu(settings, this.settings) this.save() } @@ -122,7 +137,7 @@ export class SettingsRepository { return expiresAt < now } - return true; + return true } reloadUser() { @@ -130,7 +145,7 @@ export class SettingsRepository { console.debug('Reloading user') resolveUser( this.settings.credentials.access_token, - this.settings.credentials.token_type + this.settings.credentials.token_type, ).then((user) => { this.settings.credentials.user = user this.save() diff --git a/package.json b/package.json index 3cb2422..4778b20 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "rerun-encoder", "private": true, "productName": "Rerun Encoder", - "version": "1.0.4", + "version": "1.1.0", "description": "Official Rerun Encoder App for Rerun Manager", "main": "dist-electron/main/index.js", "scripts": { @@ -59,6 +59,7 @@ "dependencies": { "@ffmpeg-installer/ffmpeg": "^1.1.0", "@koa/cors": "^4.0.0", + "@types/cookie": "^0.5.1", "axios": "^1.3.4", "cors": "^2.8.5", "defu": "^6.1.2", diff --git a/shared/schema.d.ts b/shared/schema.d.ts index 61be8bc..2ab7526 100644 --- a/shared/schema.d.ts +++ b/shared/schema.d.ts @@ -31,8 +31,22 @@ export interface Credentials { */ export interface Settings { version?: string // version of the settings schema (diff will force a reset) - credentials?: Credentials | null, - endpoint?: string | null, + credentials?: Credentials | null + endpoint?: string | null + deleteOnComplete?: boolean + output?: { + video: { + encoder: 'libx264' | 'nvenc_h264' + bitrate: number + } + audio: { + encoder: 'aac' + bitrate: number + } + preset: 'default' | 'fast' | 'medium' | 'slow' + profile: 'high' | 'main' | 'baseline' + crf: number + } } export interface VerifiedGame { diff --git a/src/App.vue b/src/App.vue index 709d1a2..126bced 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,14 +1,16 @@ + + + + \ No newline at end of file diff --git a/src/style.css b/src/style.css index 98baad6..f8e9e72 100644 --- a/src/style.css +++ b/src/style.css @@ -7,6 +7,6 @@ body { -webkit-app-region: drag; } -button, a, input, label { +button, a, input, label, .no-drag { -webkit-app-region: no-drag; } \ No newline at end of file