mirror of
https://github.com/bitinflow/rerun-encoder.git
synced 2026-03-13 13:46:00 +00:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d01dc65bad | ||
|
|
63fe03574f | ||
|
|
9d1f43e679 | ||
|
|
a67f136697 | ||
|
|
5f96ca82ea | ||
|
|
76a5e4a4c8 | ||
|
|
5d5b62d9d3 | ||
|
|
d27ecf4b3d | ||
|
|
4ba1f1fb5f | ||
|
|
03a92e9d85 | ||
|
|
df803afe2b |
@@ -1,8 +1,27 @@
|
||||
import {BrowserWindow} from "electron";
|
||||
import {User} from "../../shared/schema";
|
||||
import axios from "axios";
|
||||
|
||||
export function emit(event: any, ...args: any) {
|
||||
// Send a message to all windows
|
||||
BrowserWindow.getAllWindows().forEach((win) => {
|
||||
win.webContents.send(event, ...args)
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveUser(accessToken: string, tokenType: string): Promise<User> {
|
||||
const response = await axios.get('https://api.rerunmanager.com/v1/channels/me', {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `${tokenType} ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: response.data.id,
|
||||
name: response.data.name,
|
||||
config: response.data.config,
|
||||
avatar_url: response.data.avatar_url,
|
||||
premium: response.data.premium,
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import {app, BrowserWindow, ipcMain, shell} from 'electron'
|
||||
import {release} from 'node:os'
|
||||
import {join} from 'node:path'
|
||||
import {Settings} from "../../shared/schema";
|
||||
import {EncoderOptions, Settings} from "../../shared/schema";
|
||||
import {SettingsRepository} from "../rerun-manager/settings-repository";
|
||||
import {Encoder, EncoderOptions} from "../rerun-manager/encoder";
|
||||
import IpcMainInvokeEvent = Electron.IpcMainInvokeEvent;
|
||||
import {Encoder} from "../rerun-manager/encoder";
|
||||
import {InternalServer} from "../rerun-manager/internal-server";
|
||||
import {emit} from "./helpers";
|
||||
import {platform} from "node:process";
|
||||
import IpcMainInvokeEvent = Electron.IpcMainInvokeEvent;
|
||||
|
||||
// The built directory structure
|
||||
//
|
||||
@@ -41,7 +41,11 @@ if (require('electron-squirrel-startup')) {
|
||||
app.quit()
|
||||
}
|
||||
|
||||
require('update-electron-app')()
|
||||
require('update-electron-app')({
|
||||
repo: 'bitinflow/rerun-encoder',
|
||||
updateInterval: '1 hour',
|
||||
logger: require('electron-log')
|
||||
})
|
||||
|
||||
// Remove electron security warnings
|
||||
// This warning only shows in development mode
|
||||
@@ -49,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
|
||||
@@ -75,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
|
||||
@@ -89,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'}
|
||||
})
|
||||
@@ -150,20 +156,21 @@ ipcMain.handle('open-win', (_, arg) => {
|
||||
|
||||
ipcMain.handle('version', async () => app.getVersion())
|
||||
ipcMain.handle('settings', async () => settingsRepository.getSettings())
|
||||
ipcMain.handle('logout', async () => settingsRepository.logout())
|
||||
ipcMain.handle('logout', async () => await settingsRepository.logout())
|
||||
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),
|
||||
onUploadComplete: (id, video) => event.sender.send('encode-upload-complete', id, video),
|
||||
onError: (id, error) => event.sender.send('encode-error', id, error),
|
||||
}, settingsRepository.getSettings());
|
||||
return await encoder.encode()
|
||||
}, 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));
|
||||
|
||||
|
||||
@@ -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})
|
||||
},
|
||||
|
||||
@@ -1,101 +1,131 @@
|
||||
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, Settings, User, Video} from "../../shared/schema";
|
||||
import * as fs from "fs";
|
||||
import axios, {AxiosInstance} from "axios";
|
||||
|
||||
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 settings: Settings;
|
||||
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,
|
||||
settings: Settings
|
||||
settingsRepository: SettingsRepository,
|
||||
) {
|
||||
this.id = id;
|
||||
this.input = input;
|
||||
this.output = this.input.replace(/\.mp4$/, '.flv')
|
||||
this.options = options;
|
||||
this.listeners = listeners;
|
||||
this.settings = settings;
|
||||
|
||||
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()
|
||||
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({})
|
||||
}
|
||||
|
||||
async encode(): Promise<void> {
|
||||
encode(): void {
|
||||
this.signal.aborted = false
|
||||
this.listeners.onStart(this.id)
|
||||
|
||||
const ffmpegPath = require('@ffmpeg-installer/ffmpeg').path;
|
||||
ffmpeg.setFfmpegPath(ffmpegPath)
|
||||
if (this.requireEncoding()) {
|
||||
let totalTime = 0
|
||||
const outputOptions = this.getOutputOptions()
|
||||
|
||||
ffmpeg(this.input)
|
||||
.outputOptions(this.getOutputOptions())
|
||||
.output(this.output)
|
||||
.on('start', () => {
|
||||
console.log('start')
|
||||
})
|
||||
.on('progress', (progress) => {
|
||||
console.log('progress', progress)
|
||||
this.listeners.onProgress(this.id, progress.percent)
|
||||
})
|
||||
.on('end', async () => {
|
||||
console.log('end')
|
||||
// @ts-ignore
|
||||
try {
|
||||
const video = await this.requestUploadUrl(this.settings.credentials.user)
|
||||
await this.upload(video)
|
||||
} catch (error) {
|
||||
console.log('Using the following output options:', outputOptions)
|
||||
|
||||
ffmpegCommand = ffmpeg(this.input)
|
||||
ffmpegCommand
|
||||
.output(this.output)
|
||||
.outputOptions(outputOptions)
|
||||
.on('start', () => console.log('start'))
|
||||
.on('codecData', data => totalTime = parseInt(data.duration.replace(/:/g, '')))
|
||||
.on('progress', progress => {
|
||||
const time = parseInt(progress.timemark.replace(/:/g, ''))
|
||||
const percent = (time / totalTime) * 100
|
||||
console.log('progress', percent)
|
||||
this.listeners.onProgress(this.id, percent)
|
||||
})
|
||||
.on('end', async () => this.requestUpload(this.output))
|
||||
.on('error', (error) => {
|
||||
console.log('error', error)
|
||||
this.listeners.onError(this.id, error.message)
|
||||
}
|
||||
})
|
||||
.on('error', (error) => {
|
||||
console.log('error', error)
|
||||
this.listeners.onError(this.id, error.message)
|
||||
})
|
||||
.run()
|
||||
})
|
||||
.run()
|
||||
} else {
|
||||
this.requestUpload(this.input)
|
||||
}
|
||||
}
|
||||
|
||||
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 requestUploadUrl(user: User): Promise<Video> {
|
||||
private async requestUpload(filename: string): Promise<void> {
|
||||
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(this.output).size,
|
||||
size: fs.statSync(filename).size,
|
||||
})
|
||||
|
||||
return response.data as Video
|
||||
const video = response.data as Video
|
||||
|
||||
try {
|
||||
await this.handleUpload(video, filename)
|
||||
} catch (error) {
|
||||
console.log('error', error)
|
||||
this.listeners.onError(this.id, error.message)
|
||||
}
|
||||
}
|
||||
|
||||
private async confirmUpload(video: Video): Promise<Video> {
|
||||
@@ -105,36 +135,31 @@ export class Encoder {
|
||||
|
||||
console.log('confirm', response.data)
|
||||
|
||||
return response.data as Video;
|
||||
return response.data as Video
|
||||
}
|
||||
|
||||
private async cancelUpload(video: Video): Promise<void> {
|
||||
console.log('cancel upload', video.id)
|
||||
await this.api.delete(`videos/${video.id}/cancel`)
|
||||
}
|
||||
|
||||
private async upload(video: Video) {
|
||||
private async handleUpload(video: Video, filename: string) {
|
||||
if (!video.upload_url) {
|
||||
return
|
||||
}
|
||||
|
||||
const stream = fs.createReadStream(this.output)
|
||||
stream.on('error', (error) => {
|
||||
console.log('upload error', error)
|
||||
this.listeners.onError(this.id, error.message)
|
||||
})
|
||||
|
||||
const progress = (progress: any) => {
|
||||
const progressCompleted = progress.loaded / fs.statSync(this.output).size * 100
|
||||
this.listeners.onUploadProgress(this.id, progressCompleted)
|
||||
if (this.signal.aborted) {
|
||||
console.log('upload aborted, skipping upload handle')
|
||||
this.listeners.onError(this.id, `Upload Error: Upload aborted`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await this.s3.put(video.upload_url, stream, {
|
||||
onUploadProgress: progress,
|
||||
headers: {
|
||||
'Content-Type': 'video/x-flv',
|
||||
}
|
||||
})
|
||||
await upload(video.upload_url, filename, (progress: number) => {
|
||||
this.listeners.onUploadProgress(this.id, progress)
|
||||
}, this.signal)
|
||||
|
||||
this.settingsRepository.reloadUser()
|
||||
|
||||
this.listeners.onUploadComplete(this.id, await this.confirmUpload(video))
|
||||
} catch (error) {
|
||||
@@ -147,4 +172,30 @@ export class Encoder {
|
||||
this.listeners.onError(this.id, `Upload Error: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This will return the output path for the encoded file.
|
||||
* It will have the same name as the input file, but with the given extension.
|
||||
* Also, a suffix will be added to the file name to avoid overwriting existing files.
|
||||
*/
|
||||
private getOutputPath(input: string, ext: string) {
|
||||
const parsed = path.parse(input)
|
||||
return path.join(parsed.dir, `${parsed.name}-rerun.${ext}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* You can also just upload the file as is, without encoding it.
|
||||
* @private
|
||||
*/
|
||||
private requireEncoding() {
|
||||
return path.parse(this.input).ext !== '.flv'
|
||||
}
|
||||
|
||||
cancel() {
|
||||
console.log('cancel rerun encode')
|
||||
this.signal.aborted = true
|
||||
if (ffmpegCommand) {
|
||||
ffmpegCommand.kill()
|
||||
}
|
||||
}
|
||||
}
|
||||
47
electron/rerun-manager/file-uploader.ts
Normal file
47
electron/rerun-manager/file-uploader.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Signal } from './encoder'
|
||||
|
||||
const fs = require('fs');
|
||||
const https = require('https');
|
||||
const {promisify} = require('util');
|
||||
|
||||
export async function upload(url, filename, onProgress, signal: Signal): Promise<void> {
|
||||
const fileStream = fs.createReadStream(filename);
|
||||
const fileStats = await promisify(fs.stat)(filename);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'video/x-flv',
|
||||
'Content-Length': fileStats.size,
|
||||
},
|
||||
agent: false // Disable HTTP keep-alive
|
||||
}
|
||||
|
||||
const req = https.request(url, options, (res) => {
|
||||
if (res.statusCode >= 400) {
|
||||
reject(new Error(`Failed to upload file: ${res.statusCode} ${res.statusMessage}`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
|
||||
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));
|
||||
});
|
||||
|
||||
fileStream.on('error', reject);
|
||||
fileStream.pipe(req);
|
||||
});
|
||||
}
|
||||
@@ -6,10 +6,11 @@ import koaCors from "koa-cors";
|
||||
import bodyParser from "koa-bodyparser";
|
||||
import axios, {AxiosInstance} from "axios";
|
||||
import {app} from "electron"
|
||||
import {Credentials, User} from "../../shared/schema";
|
||||
import {Credentials} from "../../shared/schema";
|
||||
import {SettingsRepository} from "./settings-repository";
|
||||
import * as fs from "fs";
|
||||
import {join} from "node:path";
|
||||
import {resolveUser} from "../main/helpers";
|
||||
|
||||
export class InternalServer {
|
||||
private readonly app: Application;
|
||||
@@ -33,7 +34,7 @@ export class InternalServer {
|
||||
});
|
||||
|
||||
router.get('/oauth', (ctx) => {
|
||||
if(process.env.VITE_DEV_SERVER_URL) {
|
||||
if (process.env.VITE_DEV_SERVER_URL) {
|
||||
ctx.body = fs.readFileSync(join(__dirname, '..', '..', 'public', 'callback.html'), {encoding: 'utf8'})
|
||||
} else {
|
||||
ctx.body = fs.readFileSync(join(process.env.DIST, 'callback.html'), {encoding: 'utf8'})
|
||||
@@ -50,7 +51,7 @@ export class InternalServer {
|
||||
expires_in: params.get('expires_in'),
|
||||
expires_at: this.calculateExpiresAt(params.get('expires_in')),
|
||||
state: params.get('state'),
|
||||
user: await this.resolveUser(params.get('access_token'), params.get('token_type')),
|
||||
user: await resolveUser(params.get('access_token'), params.get('token_type')),
|
||||
}
|
||||
|
||||
console.log('credentials', credentials);
|
||||
@@ -76,23 +77,6 @@ export class InternalServer {
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveUser(accessToken: string, tokenType: string): Promise<User> {
|
||||
const response = await this.axios.get('https://api.rerunmanager.com/v1/channels/me', {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `${tokenType} ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: response.data.id,
|
||||
name: response.data.name,
|
||||
config: response.data.config,
|
||||
avatar_url: response.data.avatar_url,
|
||||
premium: response.data.premium,
|
||||
}
|
||||
}
|
||||
|
||||
private calculateExpiresAt(expiresIn: string) {
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + parseInt(expiresIn) * 1000);
|
||||
|
||||
@@ -1,19 +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 { 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/',
|
||||
keep_encoded_file: 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 = []
|
||||
@@ -37,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')
|
||||
@@ -58,7 +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()
|
||||
}
|
||||
|
||||
await this.save()
|
||||
@@ -69,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)
|
||||
@@ -86,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() {
|
||||
@@ -103,6 +120,7 @@ export class SettingsRepository {
|
||||
}
|
||||
|
||||
commitSettings(settings: Settings) {
|
||||
console.log('Committing settings', settings)
|
||||
this.settings = defu(settings, this.settings)
|
||||
this.save()
|
||||
}
|
||||
@@ -119,6 +137,21 @@ export class SettingsRepository {
|
||||
return expiresAt < now
|
||||
}
|
||||
|
||||
return true;
|
||||
return true
|
||||
}
|
||||
|
||||
reloadUser() {
|
||||
try {
|
||||
console.debug('Reloading user')
|
||||
resolveUser(
|
||||
this.settings.credentials.access_token,
|
||||
this.settings.credentials.token_type,
|
||||
).then((user) => {
|
||||
this.settings.credentials.user = user
|
||||
this.save()
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Failed to reload user', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
10
package.json
10
package.json
@@ -2,7 +2,7 @@
|
||||
"name": "rerun-encoder",
|
||||
"private": true,
|
||||
"productName": "Rerun Encoder",
|
||||
"version": "1.0.2",
|
||||
"version": "1.1.0",
|
||||
"description": "Official Rerun Encoder App for Rerun Manager",
|
||||
"main": "dist-electron/main/index.js",
|
||||
"scripts": {
|
||||
@@ -46,22 +46,24 @@
|
||||
"@types/koa-router": "^7.4.4",
|
||||
"@vitejs/plugin-vue": "^4.0.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"electron": "^23.1.1",
|
||||
"electron": "^25.0.1",
|
||||
"postcss": "^8.4.21",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"typescript": "^4.9.5",
|
||||
"typescript": "^5.0.4",
|
||||
"vite": "^4.1.4",
|
||||
"vite-plugin-electron": "^0.11.1",
|
||||
"vite-plugin-electron-renderer": "^0.12.1",
|
||||
"vite-plugin-electron-renderer": "^0.14.5",
|
||||
"vue": "^3.2.47",
|
||||
"vue-tsc": "^1.1.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",
|
||||
"electron-log": "^5.0.0-beta.16",
|
||||
"electron-squirrel-startup": "^1.0.0",
|
||||
"express": "^4.18.2",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
|
||||
18
shared/schema.d.ts
vendored
18
shared/schema.d.ts
vendored
@@ -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
|
||||
keep_encoded_file?: 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 {
|
||||
|
||||
52
src/App.vue
52
src/App.vue
@@ -1,14 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, onMounted, ref} from "vue";
|
||||
import ProgressBar from "./components/ProgressBar.vue";
|
||||
import AppTitleBar from "./components/AppTitleBar.vue";
|
||||
import VButton from "./components/VButton.vue";
|
||||
import {Settings, Video} from "../shared/schema";
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import ProgressBar from './components/ProgressBar.vue'
|
||||
import AppTitleBar from './components/AppTitleBar.vue'
|
||||
import VButton from './components/VButton.vue'
|
||||
import { Settings, Video } from '../shared/schema'
|
||||
import AppEncoderOptions from './components/AppEncoderOptions.vue'
|
||||
|
||||
console.log("[App.vue]", `Hello world from Electron ${process.versions.electron}!`)
|
||||
console.log('[App.vue]', `Hello world from Electron ${process.versions.electron}!`)
|
||||
|
||||
const isEncoding = ref(false)
|
||||
const isCompleted = ref(false)
|
||||
const isOptionsOpen = ref(false)
|
||||
const encodingProgress = ref(0)
|
||||
const encodingProgressStage = ref<string>('unknown')
|
||||
const encodingError = ref<string | null>(null)
|
||||
@@ -83,7 +85,7 @@ const requestEncode = (event: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
const settings = ref<Settings | null>(null);
|
||||
const settings = ref<Settings | null>(null)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// Authorization Code Grant with PKCE
|
||||
@@ -107,6 +109,15 @@ const logout = () => {
|
||||
window.api.logout()
|
||||
}
|
||||
|
||||
const cancelEncode = () => {
|
||||
// @ts-ignore
|
||||
window.api.cancelEncode()
|
||||
}
|
||||
|
||||
const openOptions = () => {
|
||||
isOptionsOpen.value = true
|
||||
}
|
||||
|
||||
const storageUpgradeRequired = computed(() => {
|
||||
if (!settings.value) return false
|
||||
if (!settings.value.credentials) return false
|
||||
@@ -132,15 +143,25 @@ onMounted(() => {
|
||||
<div>
|
||||
<app-title-bar/>
|
||||
|
||||
<div class="px-6 flex">
|
||||
<div class="px-6 flex" v-if="!isOptionsOpen">
|
||||
<div>
|
||||
<img src="./assets/ffmpeg.svg" class="h-16" alt="ffmpeg">
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold text-xl mt-2">Rerun Encoder</div>
|
||||
<div class="text-sm text-zinc-400">Powered by FFmpeg</div>
|
||||
<div class="text-sm text-zinc-400">
|
||||
Powered by FFmpeg ({{ settings?.output?.video?.encoder }})
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 mt-4">
|
||||
<a
|
||||
class="text-rose-400"
|
||||
href="#"
|
||||
@click.prevent="openOptions"
|
||||
>
|
||||
<i class="far fa-gear"></i>
|
||||
Options
|
||||
</a>
|
||||
<a
|
||||
class="text-rose-400"
|
||||
href="https://www.bitinflow.com/legal/privacy/"
|
||||
@@ -160,9 +181,15 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-encoder-options
|
||||
v-if="settings && isOptionsOpen"
|
||||
:settings="settings"
|
||||
@close="isOptionsOpen = false"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
<div v-if="!isOptionsOpen">
|
||||
<div v-if="settings && settings.credentials" class="p-4 flex justify-between bg-base-900">
|
||||
<div class="test flex gap-2">
|
||||
<div>
|
||||
@@ -212,6 +239,11 @@ onMounted(() => {
|
||||
</div>
|
||||
<template v-else-if="isEncoding">
|
||||
<progress-bar :progress="encodingProgress" :stage="encodingProgressStage"/>
|
||||
<div>
|
||||
<button class="text-rose-400/80 text-sm" @click="cancelEncode">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<input
|
||||
|
||||
201
src/components/AppEncoderOptions.vue
Normal file
201
src/components/AppEncoderOptions.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<script setup lang="ts">
|
||||
import { PropType } from 'vue'
|
||||
import { Settings } from '../../shared/schema'
|
||||
|
||||
const props = defineProps({
|
||||
settings: Object as PropType<Settings>,
|
||||
isOptionsOpen: Boolean,
|
||||
onOptionsOpen: Function as PropType<() => void>,
|
||||
onOptionsClose: Function as PropType<() => void>,
|
||||
})
|
||||
|
||||
const emits = defineEmits(['close', 'commit-settings'])
|
||||
|
||||
// watch for changes to the settings object
|
||||
const commitSettings = () => {
|
||||
const clone = JSON.parse(JSON.stringify(props.settings))
|
||||
// commit the settings object to the main process
|
||||
// @ts-ignore
|
||||
window.api.commitSettings(clone)
|
||||
console.log('settings updated', clone)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="px-6 pb-6 no-drag grid gap-2"
|
||||
style="max-height: 220px; overflow-y: auto;"
|
||||
>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-white/50"
|
||||
@click="emits('close')"
|
||||
>
|
||||
<i class="fal fa-arrow-left mr-1"></i>
|
||||
Go Back
|
||||
</button>
|
||||
</div>
|
||||
<div class="font-bold text-sm text-white/80">Encoding</div>
|
||||
<div
|
||||
v-if="props.settings && props.settings.output"
|
||||
class="grid gap-3"
|
||||
>
|
||||
<div class="bg-base-600/40 rounded flex items-center p-3 gap-4 justify-between">
|
||||
<div>
|
||||
<div class="text-sm">Video Encoder</div>
|
||||
<div class="text-xs text-white/50">
|
||||
If available, use hardware encoding.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<select
|
||||
class="w-full rounded bg-base-700 text-white/50 border-0 py-1.5"
|
||||
v-model="props.settings.output.video.encoder"
|
||||
@change="commitSettings"
|
||||
>
|
||||
<option value="libx264">Software (libx264)</option>
|
||||
<option value="nvenc_h264">Hardware (NVENC, H.264)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-base-600/40 rounded flex items-center p-3 gap-4 justify-between">
|
||||
<div class="self-center">
|
||||
<div class="text-sm">Video Bitrate</div>
|
||||
<div class="text-xs text-white/50">
|
||||
Select your desired video bitrate.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<select
|
||||
class="w-full rounded bg-base-700 text-white/50 border-0 py-1.5"
|
||||
v-model.number="props.settings.output.video.bitrate"
|
||||
@change="commitSettings"
|
||||
>
|
||||
<option value="6000">6000 kbps (1080p @ 60 FPS)</option>
|
||||
<option value="4500">4500 kbps (1080p @ 30 FPS)</option>
|
||||
<option value="4500">4500 kbps (720p @ 60 FPS)</option>
|
||||
<option value="3000">3000 kbps (720p @ 30 FPS)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-base-600/40 rounded flex items-center p-3 gap-4 justify-between">
|
||||
<div class="self-center">
|
||||
<div class="text-sm">Audio Encoder</div>
|
||||
<div class="text-xs text-white/50">
|
||||
Select your desired audio encoder.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<select
|
||||
class="w-full rounded bg-base-700 text-white/50 border-0 py-1.5"
|
||||
v-model="props.settings.output.audio.encoder"
|
||||
@change="commitSettings"
|
||||
>
|
||||
<option value="aac">AAC</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-base-600/40 rounded flex items-center p-3 gap-4 justify-between">
|
||||
<div class="self-center">
|
||||
<div class="text-sm">Audio Bitrate</div>
|
||||
<div class="text-xs text-white/50">
|
||||
We recommend using 192 kbps.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<select
|
||||
class="w-full rounded bg-base-700 text-white/50 border-0 py-1.5"
|
||||
v-model.number="props.settings.output.audio.bitrate"
|
||||
@change="commitSettings"
|
||||
>
|
||||
<option value="64">64 kbps</option>
|
||||
<option value="96">96 kbps</option>
|
||||
<option value="128">128 kbps</option>
|
||||
<option value="160">160 kbps</option>
|
||||
<option value="192" selected>192 kbps</option>
|
||||
<option value="224">224 kbps</option>
|
||||
<option value="256">256 kbps</option>
|
||||
<option value="288">288 kbps</option>
|
||||
<option value="320">320 kbps</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-base-600/40 rounded flex items-center p-3 gap-4 justify-between">
|
||||
<div class="self-center">
|
||||
<div class="text-sm">Preset</div>
|
||||
<div class="text-xs text-white/50">
|
||||
We recommend using the fast preset.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<select
|
||||
class="w-full rounded bg-base-700 text-white/50 border-0 py-1.5"
|
||||
v-model="props.settings.output.preset"
|
||||
@change="commitSettings"
|
||||
>
|
||||
<option value="default">Default</option>
|
||||
<option value="fast">Fast</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="slow">Slow</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-base-600/40 rounded flex items-center p-3 gap-4 justify-between">
|
||||
<div class="self-center">
|
||||
<div class="text-sm">Profile</div>
|
||||
<div class="text-xs text-white/50">
|
||||
We recommend using the high profile.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<select
|
||||
class="w-full rounded bg-base-700 text-white/50 border-0 py-1.5"
|
||||
v-model="props.settings.output.profile"
|
||||
@change="commitSettings"
|
||||
>
|
||||
<option value="high">High</option>
|
||||
<option value="main">Main</option>
|
||||
<option value="baseline">Baseline</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-base-600/40 rounded flex items-center p-3 gap-4 justify-between">
|
||||
<div class="self-center">
|
||||
<div class="text-sm">CRF</div>
|
||||
<div class="text-xs text-white/50">
|
||||
Lower values mean higher quality, 17-28 is usually a good range.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="51"
|
||||
step="1"
|
||||
class="w-full rounded bg-base-700 text-white/50 border-0 py-1.5"
|
||||
v-model.number="props.settings.output.crf"
|
||||
@change="commitSettings"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -7,6 +7,6 @@ body {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
button, a, input, label {
|
||||
button, a, input, label, .no-drag {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
Reference in New Issue
Block a user