├── .browserslistrc ├── src ├── background │ ├── util.ts │ ├── config.ts │ ├── workStep.ts │ ├── api.ts │ ├── renderer.ts │ └── analyzer.ts ├── assets │ └── logo.png ├── shims-vue.d.ts ├── shims-tsx.d.ts ├── types │ ├── api.ts │ ├── mediaInfo.ts │ └── config.ts ├── main.ts ├── components │ ├── Done.vue │ ├── Progress.vue │ ├── FilePicker.vue │ ├── Rendering.vue │ ├── HelloWorld.vue │ ├── Analysis.vue │ └── Settings.vue ├── App.vue ├── background.ts └── store │ └── index.ts ├── babel.config.js ├── public ├── icon.png ├── favicon.ico └── index.html ├── images └── media-conformer.png ├── .editorconfig ├── .prettierrc.json ├── vue.config.js ├── .gitignore ├── tsconfig.json ├── .eslintrc.js ├── package.json └── README.md /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | -------------------------------------------------------------------------------- /src/background/util.ts: -------------------------------------------------------------------------------- 1 | export function literal(arg: T): T { 2 | return arg 3 | } 4 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@vue/cli-plugin-babel/preset'] 3 | } 4 | -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mint-dewit/media-conformer/HEAD/public/icon.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mint-dewit/media-conformer/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mint-dewit/media-conformer/HEAD/src/assets/logo.png -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import Vue from 'vue' 3 | export default Vue 4 | } 5 | -------------------------------------------------------------------------------- /images/media-conformer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mint-dewit/media-conformer/HEAD/images/media-conformer.png -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = false 9 | end_of_line = lf -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSpacing": true, 4 | "trailingComma": "none", 5 | "singleQuote": true, 6 | "printWidth": 100, 7 | "useTabs": true, 8 | "endOfLine": "lf", 9 | "semi": false 10 | } 11 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | // vue.config.js 2 | module.exports = { 3 | pluginOptions: { 4 | electronBuilder: { 5 | mainProcessWatch: ['src/background/**.ts'], 6 | builderOptions: { 7 | win: { 8 | target: 'portable', 9 | icon: './public/icon.png' 10 | } 11 | } 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/shims-tsx.d.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VNode } from 'vue' 2 | 3 | declare global { 4 | namespace JSX { 5 | // tslint:disable no-empty-interface 6 | interface Element extends VNode {} 7 | // tslint:disable no-empty-interface 8 | interface ElementClass extends Vue {} 9 | interface IntrinsicElements { 10 | [elem: string]: any 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/types/api.ts: -------------------------------------------------------------------------------- 1 | import { Analysis } from './mediaInfo' 2 | 3 | export interface ProgressReport { 4 | path: string 5 | progress: number 6 | type: 'analysis' | 'renderer' 7 | } 8 | 9 | export interface AnalysisResult { 10 | path: string 11 | analysis: Analysis 12 | } 13 | export interface RenderingResult { 14 | path: string 15 | outputs: Array 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | # Editor directories and files 16 | .idea 17 | .vscode 18 | *.suo 19 | *.ntvs* 20 | *.njsproj 21 | *.sln 22 | *.sw? 23 | 24 | #Electron-builder output 25 | /dist_electron 26 | 27 | scratch -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | // "module": "esnext", 5 | "module": "commonjs", 6 | "strict": true, 7 | "jsx": "preserve", 8 | "importHelpers": true, 9 | "moduleResolution": "node", 10 | "experimentalDecorators": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "sourceMap": true, 14 | "baseUrl": ".", 15 | "types": ["node", "webpack-env"], 16 | "paths": { 17 | "@/*": ["src/*"] 18 | }, 19 | "lib": ["esnext", "dom", "dom.iterable", "scripthost"] 20 | }, 21 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "tests/**/*.ts", "tests/**/*.tsx"], 22 | "exclude": ["node_modules"] 23 | } 24 | -------------------------------------------------------------------------------- /src/background/config.ts: -------------------------------------------------------------------------------- 1 | import { Config } from '@/types/config' 2 | 3 | // TODO - make editable and shippable somehow 4 | 5 | export const config: Config = { 6 | name: '', 7 | blackFrames: {}, 8 | // freezeFrames: {}, // can't use this in ubuntu 18.04 because of outdated ffmpeg 9 | borders: {}, 10 | silence: {}, 11 | interlaced: {}, 12 | loudness: true, 13 | 14 | encoders: [ 15 | { 16 | postFix: '_YOUTUBE', 17 | audioEncoder: {}, 18 | loudness: { 19 | integrated: -14 20 | }, 21 | videoEncoder: {} 22 | }, 23 | { 24 | postFix: '_TV', 25 | audioEncoder: {}, 26 | loudness: { 27 | integrated: -23 28 | }, 29 | videoEncoder: {}, 30 | format: { 31 | width: 1024 32 | } 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import { ipcRenderer } from 'electron' 3 | import Vuex from 'vuex' 4 | 5 | Vue.use(Vuex) 6 | 7 | import App from './App.vue' 8 | import { store } from './store' 9 | 10 | import { library } from '@fortawesome/fontawesome-svg-core' 11 | import { 12 | faTimes, 13 | faTrashAlt, 14 | faCheckSquare, 15 | faCog, 16 | faExclamationTriangle, 17 | faCheckCircle 18 | } from '@fortawesome/free-solid-svg-icons' 19 | import { faSquare } from '@fortawesome/free-regular-svg-icons' 20 | import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' 21 | 22 | Vue.config.productionTip = false 23 | 24 | library.add(faTimes) 25 | library.add(faTrashAlt) 26 | library.add(faCheckSquare) 27 | library.add(faSquare) 28 | library.add(faCog) 29 | library.add(faExclamationTriangle) 30 | library.add(faCheckCircle) 31 | 32 | Vue.component('fa', FontAwesomeIcon) 33 | 34 | ipcRenderer.on('progressReport', console.log) 35 | 36 | new Vue({ 37 | render: (h) => h(App), 38 | store 39 | }).$mount('#app') 40 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | extends: [ 7 | 'plugin:vue/base', 8 | 'plugin:vue/essential', 9 | 'eslint:recommended', 10 | '@vue/typescript/recommended', 11 | '@vue/prettier', 12 | '@vue/prettier/@typescript-eslint' 13 | ], 14 | parserOptions: { 15 | ecmaVersion: 2020 16 | }, 17 | rules: { 18 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 19 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 20 | 'no-use-before-define': 'off', 21 | '@typescript-eslint/no-use-before-define': 'off' 22 | }, 23 | overrides: [ 24 | { 25 | files: ['background.ts', 'background/**.ts'], 26 | parser: '@typescript-eslint/parser', 27 | parserOptions: { project: './tsconfig.json' }, 28 | plugins: ['@typescript-eslint'], 29 | extends: [ 30 | 'eslint:recommended', 31 | 'plugin:@typescript-eslint/eslint-recommended', 32 | 'plugin:@typescript-eslint/recommended', 33 | 'prettier/@typescript-eslint' 34 | ], 35 | rules: {} 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /src/components/Done.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 23 | 24 | 69 | -------------------------------------------------------------------------------- /src/background/workStep.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | import { EncoderConfig } from '@/types/config' 3 | import { Analysis } from '@/types/mediaInfo' 4 | import { ParsedPath } from 'path' 5 | 6 | export class WorkStep extends EventEmitter { 7 | input: string 8 | failed = false 9 | warnings: Array = [] 10 | 11 | progressSteps = 0 12 | stepsCompleted = 0 13 | 14 | constructor(input: string) { 15 | super() 16 | this.input = input 17 | } 18 | 19 | failStep(reason?: string, cause?: Error) { 20 | this.failed = true 21 | if (reason) { 22 | this.warnings.push(reason) 23 | } else { 24 | this.warnings.push('Failed for unknown reason') 25 | } 26 | } 27 | 28 | addWarning(reason: string) { 29 | this.warnings.push(reason) 30 | } 31 | 32 | completed(steps = 1) { 33 | this.stepsCompleted += steps 34 | 35 | this.emit('progressReport', { 36 | progress: this.stepsCompleted / this.progressSteps, 37 | path: this.input 38 | }) 39 | } 40 | } 41 | 42 | export class RenderWorkstep extends WorkStep { 43 | output: string 44 | outputParse: ParsedPath 45 | encoderConfig: EncoderConfig 46 | analysis: Analysis 47 | 48 | constructor( 49 | input: string, 50 | output: string, 51 | outputParse: ParsedPath, 52 | config: EncoderConfig, 53 | analysis: Analysis 54 | ) { 55 | super(input) 56 | this.input = input 57 | this.output = output 58 | this.outputParse = outputParse 59 | this.encoderConfig = config 60 | this.analysis = analysis 61 | } 62 | 63 | renderProgress(p: number) { 64 | this.emit('progressReport', { 65 | progress: p, 66 | path: this.output 67 | }) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "media-conformer", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint", 9 | "electron:build": "vue-cli-service electron:build", 10 | "electron:serve": "vue-cli-service electron:serve", 11 | "postinstall": "electron-builder install-app-deps", 12 | "postuninstall": "electron-builder install-app-deps" 13 | }, 14 | "main": "background.js", 15 | "dependencies": { 16 | "@fortawesome/fontawesome-svg-core": "^1.2.29", 17 | "@fortawesome/free-regular-svg-icons": "^5.13.1", 18 | "@fortawesome/free-solid-svg-icons": "^5.13.1", 19 | "@fortawesome/vue-fontawesome": "^0.1.10", 20 | "core-js": "^3.6.5", 21 | "no-try": "^1.1.3", 22 | "vue": "^2.6.11", 23 | "vue-class-component": "^7.2.3", 24 | "vue-property-decorator": "^8.4.2", 25 | "vuex": "^3.4.0" 26 | }, 27 | "devDependencies": { 28 | "@types/electron-devtools-installer": "^2.2.0", 29 | "@typescript-eslint/eslint-plugin": "^2.33.0", 30 | "@typescript-eslint/parser": "^2.33.0", 31 | "@vue/cli-plugin-babel": "~4.4.0", 32 | "@vue/cli-plugin-eslint": "~4.4.0", 33 | "@vue/cli-plugin-typescript": "~4.4.0", 34 | "@vue/cli-service": "~4.4.0", 35 | "@vue/eslint-config-prettier": "^6.0.0", 36 | "@vue/eslint-config-typescript": "^5.0.2", 37 | "electron": "^5.0.0", 38 | "electron-devtools-installer": "^3.1.1", 39 | "eslint": "^6.7.2", 40 | "eslint-plugin-prettier": "^3.1.3", 41 | "eslint-plugin-vue": "^6.2.2", 42 | "prettier": "^1.19.1", 43 | "typescript": "~3.9.3", 44 | "vue-cli-plugin-electron-builder": "~1.4.6", 45 | "vue-template-compiler": "^2.6.11" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/components/Progress.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 45 | 46 | 67 | -------------------------------------------------------------------------------- /src/types/mediaInfo.ts: -------------------------------------------------------------------------------- 1 | export enum MediaStreamType { 2 | Audio = 'audio', 3 | Video = 'video' 4 | } 5 | 6 | export interface MediaStreamCodec { 7 | type?: MediaStreamType 8 | long_name?: string 9 | time_base?: string 10 | tag_string?: string 11 | is_avc?: string 12 | } 13 | 14 | export interface MediaStream { 15 | codec: MediaStreamCodec 16 | 17 | // video 18 | width?: number 19 | height?: number 20 | sample_aspect_ratio?: string 21 | display_aspect_ratio?: string 22 | pix_fmt?: string 23 | bits_per_raw_sample?: string 24 | color_space?: string 25 | 26 | // audio 27 | sample_fmt?: string 28 | sample_rate?: string 29 | channels?: number 30 | channel_layout?: string 31 | bits_per_sample?: number 32 | 33 | // common 34 | time_base?: string 35 | start_time?: string 36 | duration_ts?: number 37 | duration?: string 38 | 39 | bit_rate?: string 40 | max_bit_rate?: string 41 | nb_frames?: string 42 | } 43 | 44 | export interface MediaFormat { 45 | name?: string 46 | long_name?: string 47 | start_time?: string 48 | duration?: number 49 | bit_rate?: number 50 | max_bit_rate?: number 51 | } 52 | 53 | export enum FieldOrder { 54 | Unknown = 'unknown', 55 | Progressive = 'progressive', 56 | TFF = 'tff', 57 | BFF = 'bff' 58 | } 59 | 60 | export interface LoudnessInfo { 61 | integrated: number 62 | truePeak: number 63 | LRA: number 64 | threshold: number 65 | } 66 | 67 | export interface Anomalies { 68 | blacks?: Array 69 | freezes?: Array 70 | silences?: Array 71 | borders?: Array 72 | } 73 | 74 | export interface MediaInfo { 75 | name: string 76 | field_order?: FieldOrder 77 | streams?: MediaStream[] 78 | format?: MediaFormat 79 | timebase?: number 80 | loudness?: LoudnessInfo 81 | colorSpace?: string 82 | } 83 | 84 | export interface Analysis { 85 | info: MediaInfo 86 | anomalies: Anomalies 87 | } 88 | 89 | export interface Anomaly { 90 | start: number 91 | duration: number 92 | end: number 93 | } 94 | -------------------------------------------------------------------------------- /src/types/config.ts: -------------------------------------------------------------------------------- 1 | import { FieldOrder as FO } from './mediaInfo' 2 | 3 | export enum FieldOrder { 4 | TFF = FO.TFF, 5 | BFF = FO.BFF 6 | } 7 | 8 | export interface EncoderConfig { 9 | /** postfix to add to the filename */ 10 | postFix: string 11 | /** extension of the new file (e.g. .mp4) */ 12 | extension?: string 13 | /** custom options. Ignores all other options */ 14 | custom?: string 15 | /** discard streams */ 16 | discard?: { 17 | video?: boolean 18 | audio?: boolean 19 | subtitle?: boolean 20 | data?: boolean 21 | } 22 | /** Configures loudnorm filter */ 23 | loudness?: { 24 | integrated?: number 25 | truePeak?: number 26 | LRA?: number 27 | dualMono?: boolean 28 | } 29 | /** inserts scaler, rate conversion, interlacing/deinterlacing */ 30 | format?: { 31 | width?: number 32 | height?: number 33 | frameRate?: number 34 | audioRate?: string 35 | interlaced?: FieldOrder // possible values: tff or bff 36 | format?: string // ffmpeg -f option 37 | colorspace?: string 38 | } 39 | /** sets up the video encoder({} for default libx264, omit for copy) */ 40 | videoEncoder?: { 41 | encoder?: string 42 | encoderOptions?: Array 43 | } 44 | /** sets up the audio encoder ({} for default aac, omit for copy) */ 45 | audioEncoder?: { 46 | encoder?: string 47 | encoderOptions?: Array 48 | } 49 | } 50 | 51 | export interface Preset { 52 | name: string 53 | 54 | /** blackdetect filter */ 55 | blackFrames?: { 56 | blackDuration?: number 57 | blackRatio?: number 58 | blackThreshold?: number 59 | } 60 | /** freezedetect filter (requires recent ffmpeg) */ 61 | freezeFrames?: { 62 | freezeNoise?: number 63 | freezeDuration?: number 64 | } 65 | /** cropdetect filter */ 66 | borders?: { 67 | threshold?: number 68 | round?: number 69 | reset?: number 70 | } 71 | /** advanced interlace detection */ 72 | interlaced?: { 73 | analyzeTime?: number 74 | } 75 | /** generate warnings for silence */ 76 | silence?: { 77 | noise?: string 78 | duration?: string 79 | } 80 | /** enables 2-pass loudness correction */ 81 | loudness?: boolean 82 | 83 | encoders: Array 84 | } 85 | 86 | export interface Config extends Preset { 87 | paths?: { 88 | ffmpeg?: string 89 | ffprobe?: string 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 50 | 51 | 102 | -------------------------------------------------------------------------------- /src/components/FilePicker.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 69 | 70 | 112 | -------------------------------------------------------------------------------- /src/components/Rendering.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 46 | 47 | 108 | -------------------------------------------------------------------------------- /src/background.ts: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import { app, protocol, BrowserWindow } from 'electron' 4 | import { createProtocol } from 'vue-cli-plugin-electron-builder/lib' 5 | import installExtension, { VUEJS_DEVTOOLS } from 'electron-devtools-installer' 6 | const isDevelopment = process.env.NODE_ENV !== 'production' 7 | 8 | import { API } from './background/api' 9 | const api = new API() 10 | 11 | // Keep a global reference of the window object, if you don't, the window will 12 | // be closed automatically when the JavaScript object is garbage collected. 13 | let win: BrowserWindow | null 14 | 15 | // Scheme must be registered before the app is ready 16 | protocol.registerSchemesAsPrivileged([ 17 | { scheme: 'app', privileges: { secure: true, standard: true } } 18 | ]) 19 | 20 | function createWindow() { 21 | // Create the browser window. 22 | win = new BrowserWindow({ 23 | width: process.env.WEBPACK_DEV_SERVER_URL && !process.env.IS_TEST ? 1400 : 800, 24 | height: 600, 25 | webPreferences: { 26 | nodeIntegration: true 27 | } 28 | }) 29 | const id = api.registerWindow(win) 30 | 31 | if (process.env.WEBPACK_DEV_SERVER_URL) { 32 | // Load the url of the dev server if in development mode 33 | win.loadURL(process.env.WEBPACK_DEV_SERVER_URL as string) 34 | if (!process.env.IS_TEST) win.webContents.openDevTools() 35 | } else { 36 | createProtocol('app') 37 | // Load the index.html when not in development 38 | win.loadURL('app://./index.html') 39 | } 40 | 41 | win.on('closed', () => { 42 | api.unregisterWindow(id) 43 | win = null 44 | }) 45 | } 46 | 47 | // Quit when all windows are closed. 48 | app.on('window-all-closed', () => { 49 | // On macOS it is common for applications and their menu bar 50 | // to stay active until the user quits explicitly with Cmd + Q 51 | if (process.platform !== 'darwin') { 52 | app.quit() 53 | } 54 | }) 55 | 56 | app.on('activate', () => { 57 | // On macOS it's common to re-create a window in the app when the 58 | // dock icon is clicked and there are no other windows open. 59 | if (win === null) { 60 | createWindow() 61 | } 62 | }) 63 | 64 | // This method will be called when Electron has finished 65 | // initialization and is ready to create browser windows. 66 | // Some APIs can only be used after this event occurs. 67 | app.on('ready', async () => { 68 | if (isDevelopment && !process.env.IS_TEST) { 69 | // Install Vue Devtools 70 | try { 71 | await installExtension(VUEJS_DEVTOOLS) 72 | } catch (e) { 73 | console.error('Vue Devtools failed to install:', e.toString()) 74 | } 75 | } 76 | createWindow() 77 | }) 78 | 79 | // Exit cleanly on request from parent process in development mode. 80 | if (isDevelopment) { 81 | if (process.platform === 'win32') { 82 | process.on('message', (data) => { 83 | if (data === 'graceful-exit') { 84 | app.quit() 85 | } 86 | }) 87 | } else { 88 | process.on('SIGTERM', () => { 89 | app.quit() 90 | }) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/components/HelloWorld.vue: -------------------------------------------------------------------------------- 1 | 78 | 79 | 87 | 88 | 89 | 105 | -------------------------------------------------------------------------------- /src/background/api.ts: -------------------------------------------------------------------------------- 1 | // stuff for connecting frontend and backend 2 | import { ipcMain, BrowserWindow } from 'electron' 3 | import path from 'path' 4 | import { WorkStep, RenderWorkstep } from './workStep' 5 | import { Analyzer } from './analyzer' 6 | import { ProgressReport } from '@/types/api' 7 | import { Renderer } from './renderer' 8 | import { Analysis } from '@/types/mediaInfo' 9 | import { Config } from '@/types/config' 10 | 11 | export class API { 12 | analyzer: Analyzer | undefined 13 | renderer: Renderer | undefined 14 | 15 | private _windows: { [id: number]: BrowserWindow } = {} 16 | private _id = 0 17 | private config: Config | undefined 18 | 19 | constructor() { 20 | ipcMain.on('config', (_ev: any, config: Config) => { 21 | if (!this.config) { 22 | this.analyzer = new Analyzer(config) 23 | this.renderer = new Renderer(config) 24 | } else { 25 | this.analyzer!.config = config 26 | this.renderer!.config = config 27 | } 28 | console.log(config) 29 | this.config = config 30 | }) 31 | ipcMain.on('analyse', async (ev: any, file: string) => { 32 | console.log('Asked to analyse', file) 33 | if (!this.analyzer) return 34 | 35 | const worker = new WorkStep(file) 36 | worker.on('progressReport', (report) => { 37 | this._reportProgress('analysis', report) 38 | }) 39 | 40 | const analysis = await this.analyzer.analyzeFile(worker) 41 | 42 | ev.reply('analysis', { path: file, analysis }) 43 | }) 44 | 45 | ipcMain.on( 46 | 'startRender', 47 | async (ev: any, { file, analysis }: { file: string; analysis: Analysis }) => { 48 | console.log('Asked to render', file) 49 | if (!(this.config && this.renderer)) return 50 | 51 | const workers = this._createRenderSteps(file, analysis) 52 | 53 | workers.forEach((w) => 54 | w.on('progressReport', (report) => { 55 | this._reportProgress('renderer', report) 56 | }) 57 | ) 58 | 59 | // TODO - handle these errors better 60 | await Promise.all(workers.map((w) => this.renderer!.renderFile(w).catch(() => null))) 61 | 62 | ev.reply('rendered', { path: file, outputs: workers.map((w) => w.output) }) 63 | } 64 | ) 65 | } 66 | 67 | registerWindow(window: BrowserWindow) { 68 | this._windows[this._id] = window 69 | this._id++ 70 | 71 | window.webContents.send('getConfig') 72 | 73 | return this._id 74 | } 75 | 76 | unregisterWindow(id: number) { 77 | delete this._windows[id] 78 | } 79 | 80 | private _reportProgress( 81 | type: 'analysis' | 'renderer', 82 | { path, progress }: { path: string; progress: number } 83 | ) { 84 | const progressReport: ProgressReport = { 85 | type, 86 | progress, 87 | path 88 | } 89 | Object.values(this._windows).forEach((w) => { 90 | w.webContents.send('progressReport', progressReport) 91 | }) 92 | } 93 | 94 | private _createRenderSteps(file: string, analysis: Analysis) { 95 | return this.config!.encoders.map((encoderConfig) => { 96 | const p: path.ParsedPath = path.parse(file) 97 | const output = `${p.dir}/${p.name}${encoderConfig.postFix}${encoderConfig.extension || p.ext}` 98 | return new RenderWorkstep(file, output, p, encoderConfig, analysis) 99 | }) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/components/Analysis.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 97 | 98 | 149 | -------------------------------------------------------------------------------- /src/components/Settings.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 120 | 121 | 242 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # media-conformer 2 | 3 | This project aims to make ffmpeg transcoding a drag and drop operation accessible by anyone, using presets built by with expert knowledge. 4 | 5 | ![Media Conformer](https://raw.githubusercontent.com/baltedewit/media-conformer/master/images/media-conformer.png) 6 | 7 | The following goals outline the project: 8 | 9 | - Drag and drop interface 10 | - Portable preset files 11 | - Batch processing 12 | - Automated 2 pass loudness processing 13 | - Gracefully handle interlaced conversion: 14 | - interlaced to progressive 15 | - progressive to interlaced 16 | - field conversion (tff to bff and vice versa) 17 | 18 | ## Acknowledgements 19 | 20 | - The ffmpeg project for making an amazing A/V tool free and open source. 21 | - Large bits around media processing were taken from nrkno/tv-automation-media-management 22 | - This project was generated using the vue-cli and vue electron cli plugin 23 | 24 | ## Project setup 25 | 26 | ``` 27 | yarn install 28 | ``` 29 | 30 | Note: FFmpeg and FFprobe need to be in the PATH environment variable or you need to set a custom path in the application settings. 31 | 32 | ### Compiles and hot-reloads for development 33 | 34 | ``` 35 | yarn electron:serve 36 | ``` 37 | 38 | ### Compiles and minifies for production 39 | 40 | ``` 41 | yarn electron:build 42 | ``` 43 | 44 | ### Lints and fixes files 45 | 46 | ``` 47 | yarn lint 48 | ``` 49 | 50 | ## Preset files 51 | 52 | ### Example file 53 | 54 | ```jsonc 55 | { 56 | // Name of the preset to be shown in the settings: 57 | "name": "Example Config", 58 | 59 | // Analysis configuration 60 | "blackFrames": {}, 61 | "freezeFrames": {}, 62 | "borders": {}, 63 | "silence": {}, 64 | "interlaced": {}, 65 | "loudness": true, 66 | 67 | // Per file, each encoder will spawn an ffmpeg process 68 | "encoders": [ 69 | { 70 | "postFix": "_YOUTUBE", 71 | "audioEncoder": {}, 72 | "loudness": { 73 | "integrated": -14 74 | } 75 | }, 76 | { 77 | "postFix": "_TV", 78 | "audioEncoder": {}, 79 | "loudness": { 80 | "integrated": -23 81 | }, 82 | "videoEncoder": {}, 83 | "format": { 84 | // having a format defined implies a reencode will be done 85 | "width": 1024 86 | } 87 | }, 88 | { 89 | "postFix": "_NO-AUDIO", 90 | "discard": { 91 | "audio": true 92 | } 93 | } 94 | ] 95 | } 96 | ``` 97 | 98 | ### All options 99 | 100 | Note how a lot of times you can define an empty object, e.g. for the black frame analysis. Defining an empty object implies the ffmpeg defaults will be used. 101 | 102 | ```ts 103 | export interface Preset { 104 | name: string 105 | 106 | /** blackdetect filter */ 107 | blackFrames?: { 108 | blackDuration?: number 109 | blackRatio?: number 110 | blackThreshold?: number 111 | } 112 | /** freezedetect filter (requires recent ffmpeg) */ 113 | freezeFrames?: { 114 | freezeNoise?: number 115 | freezeDuration?: number 116 | } 117 | /** cropdetect filter */ 118 | borders?: { 119 | threshold?: number 120 | round?: number 121 | reset?: number 122 | } 123 | /** advanced interlace detection */ 124 | interlaced?: { 125 | analyzeTime?: number 126 | } 127 | /** generate warnings for silence */ 128 | silence?: { 129 | noise?: string 130 | duration?: string 131 | } 132 | /** enables 2-pass loudness correction */ 133 | loudness?: boolean 134 | 135 | encoders: Array 136 | } 137 | 138 | export interface EncoderConfig { 139 | /** postfix to add to the filename */ 140 | postFix: string 141 | /** extension of the new file (e.g. .mp4) */ 142 | extension?: string 143 | /** custom options. Ignores all other options */ 144 | custom?: string 145 | /** discard streams */ 146 | discard?: { 147 | video?: boolean 148 | audio?: boolean 149 | subtitle?: boolean 150 | data?: boolean 151 | } 152 | /** Configures loudnorm filter */ 153 | loudness?: { 154 | integrated?: number 155 | truePeak?: number 156 | LRA?: number 157 | dualMono?: boolean 158 | } 159 | /** inserts scaler, rate conversion, interlacing/deinterlacing */ 160 | format?: { 161 | width?: number 162 | height?: number 163 | frameRate?: number 164 | audioRate?: string 165 | interlaced?: FieldOrder // possible values: tff or bff 166 | format?: string // ffmpeg -f option 167 | colorspace?: string 168 | } 169 | /** sets up the video encoder({} for default libx264, omit for copy) */ 170 | videoEncoder?: { 171 | encoder?: string 172 | encoderOptions?: Array 173 | } 174 | /** sets up the audio encoder ({} for default aac, omit for copy) */ 175 | audioEncoder?: { 176 | encoder?: string 177 | encoderOptions?: Array 178 | } 179 | } 180 | ``` 181 | 182 | #### Custom encoder 183 | 184 | The custom encoder enables the use of complex ffmpeg encoding/filter settings. It is assumed you know how to use ffmpeg when using the custom encoder. 185 | 186 | It takes in a string of ffmpeg args and the output file path, name and extension are automatically appended. 187 | 188 | ```json 189 | "encoders": [ 190 | { 191 | "postFix": "_custom-text-overlay", 192 | "extension": ".mp4", 193 | "custom": "-vf drawtext=\"fontfile=/Windows/Fonts/arial.ttf: text='Custom text overlay': fontcolor=white: fontsize=120: box=1: boxcolor=black: boxborderw=20: x=(w-text_w)/2: y=(h-text_h)/1.4\"" 194 | } 195 | ] 196 | ``` 197 | 198 | The custom encoder supports handlebar style string replacement for customising file names. If any handlebars are detected the output filename will not be automatically appended. You will need to handle the output file yourself (e.g. `{{dir}}/{{name}}{{postFix}}_Custom-format{{extension}}`). 199 | 200 | ```json 201 | "encoders": [ 202 | { 203 | "postFix": "_Complex-Filter", 204 | "custom": "-an -filter_complex \"[0]pad=iw*2:ih[int];[int][0]overlay=W/2:0[doublewidth];[doublewidth]scale=iw/2:ih/2[scaled];[scaled]split=3[s1][s2][s3];[s1]crop=iw/3:ih:0:0[one];[s2]crop=iw/3:ih:ow:0[two];[s3]crop=iw/3:ih:ow*2:0[three]\" -map \"[one]\" -q:v 1 -sws_flags bicubic \"{{dir}}/{{name}}{{postFix}}_{{date}}_one{{ext}}\" -map \"[two]\" -q:v 1 -sws_flags bicubic \"{{dir}}/{{name}}{{postFix}}_{{date}}_two{{ext}}\" -map \"[three]\" -q:v 1 -sws_flags bicubic \"{{dir}}/{{name}}{{postFix}}_{{date}}_three{{ext}}\"" 205 | } 206 | ] 207 | ``` 208 | 209 | Available to use: 210 | 211 | ```ts 212 | postFix // EncoderConfig postfix 213 | extension? // EncoderConfig extension 214 | root // Input file root name 215 | dir // Input file directory 216 | base // Input file name with original extension 217 | ext // Input file extension 218 | name // Input file name 219 | date // Date in ISO format (YYYY-MM-DD) 220 | ``` 221 | 222 | # Contributors: 223 | - JonFranklin301 224 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import Vuex from 'vuex' 2 | import { ipcRenderer, IpcMessageEvent } from 'electron' 3 | import * as fs from 'fs' 4 | import { ProgressReport, AnalysisResult, RenderingResult } from '@/types/api' 5 | import { Analysis } from '@/types/mediaInfo' 6 | import Vue from 'vue' 7 | import { Preset, Config } from '@/types/config' 8 | 9 | export enum Steps { 10 | FilePicker, 11 | Analysis, 12 | Rendering, 13 | Done 14 | } 15 | 16 | export interface MediaConformerStore { 17 | step: Steps 18 | files: Array 19 | analysisProgress: { [path: string]: number } 20 | analysisResults: { [path: string]: Analysis } 21 | renderingProgress: { [path: string]: number } 22 | 23 | settings: { 24 | preset: number 25 | ffmpegPath?: string 26 | ffprobePath?: string 27 | } 28 | presets: Array 29 | } 30 | 31 | export const store = new Vuex.Store({ 32 | state: { 33 | step: Steps.FilePicker, 34 | files: [], 35 | analysisProgress: {}, 36 | analysisResults: {}, 37 | renderingProgress: {}, 38 | 39 | settings: { 40 | preset: 0 41 | }, 42 | presets: [ 43 | { 44 | name: 'Test preset', 45 | encoders: [] 46 | } 47 | ] 48 | }, 49 | getters: { 50 | getAnalysisProgress: (state) => (file: string) => state.analysisProgress[file] || 0 51 | }, 52 | mutations: { 53 | setStep(state, newStep: Steps) { 54 | state.step = newStep 55 | }, 56 | 57 | addFile(state, file: string) { 58 | state.files.push(file) 59 | }, 60 | 61 | setAnalysisProgress(state, { path, progress }) { 62 | Vue.set(state.analysisProgress, path, progress) 63 | }, 64 | 65 | setRenderingProgress(state, { path, progress }) { 66 | Vue.set(state.renderingProgress, path, progress) 67 | }, 68 | 69 | setAnalysis(state, { path, analysis }) { 70 | Vue.set(state.analysisResults, path, analysis) 71 | }, 72 | 73 | resetFiles(state) { 74 | Vue.set(state, 'files', []) 75 | }, 76 | 77 | resetAnalysisProgress(state) { 78 | Vue.set(state, 'analysisProgress', {}) 79 | }, 80 | 81 | resetAnalysisResults(state) { 82 | Vue.set(state, 'analysisResults', {}) 83 | }, 84 | 85 | resetRenderingProgress(state) { 86 | Vue.set(state, 'renderingProgress', {}) 87 | }, 88 | 89 | updateSettings(state, payload: Partial) { 90 | Vue.set(state, 'settings', { 91 | ...state.settings, 92 | ...payload 93 | }) 94 | }, 95 | 96 | addPreset(state, preset: Preset) { 97 | state.presets.push(preset) 98 | }, 99 | 100 | removePreset(state, index: number) { 101 | state.presets.splice(index, 1) 102 | let curIndex = state.settings.preset 103 | 104 | if (index < curIndex) { 105 | Vue.set(state.settings, 'preset', --curIndex) 106 | } else if (index === curIndex && index === state.presets.length) { 107 | Vue.set(state.settings, 'preset', --curIndex) 108 | } 109 | }, 110 | 111 | loadSettings( 112 | state, 113 | settings: { 114 | settings: MediaConformerStore['settings'] 115 | presets: MediaConformerStore['presets'] 116 | } 117 | ) { 118 | Vue.set(state, 'settings', settings.settings) 119 | Vue.set(state, 'presets', settings.presets) 120 | } 121 | }, 122 | actions: { 123 | receiveFiles({ commit }, files: Array) { 124 | files.forEach((f) => commit('addFile', f)) 125 | commit('setStep', Steps.Analysis) 126 | 127 | files.forEach((f) => ipcRenderer.send('analyse', f)) 128 | }, 129 | renderFiles({ commit, state }) { 130 | commit('setStep', Steps.Rendering) 131 | 132 | // wait for UI to transition, then send command 133 | setTimeout(() => { 134 | state.files.forEach((f) => 135 | ipcRenderer.send('startRender', { 136 | file: f, 137 | analysis: state.analysisResults[f] 138 | }) 139 | ) 140 | }, 300) 141 | }, 142 | updateSettings({ commit }, payload: Partial) { 143 | commit('updateSettings', payload) 144 | 145 | debouncedSendSettings() 146 | }, 147 | async receivePresets({ commit }, files: Array) { 148 | const ps: Array> = [] 149 | files.forEach((f) => { 150 | ps.push( 151 | new Promise((resolve) => { 152 | try { 153 | const file = fs.readFileSync(f, { encoding: 'utf8' }) 154 | const preset = JSON.parse(file) 155 | // verify if it is a preset: needs at least a name and encoders 156 | if (!preset.name || !preset.encoders || !preset.encoders.length) { 157 | console.log('invalid config', preset) 158 | resolve() 159 | return 160 | } 161 | commit('addPreset', preset) 162 | resolve() 163 | } catch (e) { 164 | // report to user 165 | } 166 | }) 167 | ) 168 | }) 169 | await Promise.all(ps) 170 | 171 | debouncedSendSettings() 172 | }, 173 | removePreset({ commit }, index) { 174 | commit('removePreset', index) 175 | 176 | debouncedSendSettings() 177 | }, 178 | reset({ commit }) { 179 | commit('resetFiles') 180 | commit('resetAnalysisProgress') 181 | commit('resetAnalysisResults') 182 | commit('resetRenderingProgress') 183 | commit('setStep', Steps.FilePicker) 184 | } 185 | } 186 | }) 187 | 188 | let timeout: NodeJS.Timeout | null = null 189 | function debouncedSendSettings() { 190 | if (timeout) clearTimeout(timeout) 191 | timeout = setTimeout(() => { 192 | sendSettings() 193 | saveSettingsAndPresets() 194 | }, 500) 195 | } 196 | 197 | function sendSettings() { 198 | console.log('sendign config') 199 | const config: Config = { 200 | ...store.state.presets[store.state.settings.preset] 201 | } 202 | 203 | if (store.state.settings.ffmpegPath || store.state.settings.ffprobePath) { 204 | config.paths = { 205 | ffmpeg: store.state.settings.ffmpegPath, 206 | ffprobe: store.state.settings.ffprobePath 207 | } 208 | } 209 | 210 | ipcRenderer.send('config', config) 211 | } 212 | 213 | function saveSettingsAndPresets() { 214 | localStorage.setItem( 215 | 'settings', 216 | JSON.stringify({ 217 | settings: store.state.settings, 218 | presets: store.state.presets 219 | }) 220 | ) 221 | } 222 | 223 | function loadSettingsAndPresets() { 224 | const item = localStorage.getItem('settings') 225 | if (item) { 226 | try { 227 | const settings = JSON.parse(item) 228 | store.commit('loadSettings', settings) 229 | } catch (e) { 230 | // nothing to worry about 231 | } 232 | } 233 | } 234 | 235 | ipcRenderer.on('getConfig', () => { 236 | sendSettings() 237 | }) 238 | 239 | ipcRenderer.on( 240 | 'progressReport', 241 | (_ev: IpcMessageEvent, { path, progress, type }: ProgressReport) => { 242 | if (type === 'analysis') { 243 | store.commit('setAnalysisProgress', { path, progress }) 244 | } else { 245 | store.commit('setRenderingProgress', { path, progress }) 246 | } 247 | } 248 | ) 249 | 250 | ipcRenderer.on('analysis', (_ev: IpcMessageEvent, { path, analysis }: AnalysisResult) => { 251 | // console.log(analysis) 252 | store.commit('setAnalysis', { path, analysis }) 253 | }) 254 | ipcRenderer.on('rendered', (_ev: IpcMessageEvent, { path, outputs }: RenderingResult) => { 255 | console.log('Done rendering', path) 256 | outputs.forEach((output) => { 257 | store.commit('setRenderingProgress', { path: output, progress: 1 }) 258 | }) 259 | 260 | const unfunished = Object.entries(store.state.renderingProgress).find( 261 | ([path, progress]) => progress < 1 && path 262 | ) 263 | if (!unfunished) { 264 | store.commit('setStep', Steps.Done) 265 | } 266 | }) 267 | 268 | loadSettingsAndPresets() 269 | sendSettings() 270 | -------------------------------------------------------------------------------- /src/background/renderer.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | import { spawn, ChildProcess } from 'child_process' 3 | import { Config } from '@/types/config' 4 | import { RenderWorkstep } from './workStep' 5 | import { FieldOrder } from '@/types/mediaInfo' 6 | 7 | export class Renderer extends EventEmitter { 8 | config: Config 9 | 10 | constructor(config: Config) { 11 | super() 12 | 13 | this.config = config 14 | } 15 | 16 | async renderFile(step: RenderWorkstep) { 17 | console.log('Rendering ' + step.input + ' to ' + step.output) 18 | 19 | step.renderProgress(0) // inform front end of existence 20 | 21 | const args = this._getProcessArgs(step) 22 | console.log(args) 23 | 24 | // frame= 439 fps= 26 q=-1.0 Lsize= 18008kB time=00:00:17.44 bitrate=8458.8kbits/s speed=1.02x 25 | // frame= 240 fps=0.0 q=-1.0 size= 3584kB time=00:00:09.48 bitrate=3097.1kbits/s speed=18.9x 26 | const progressReportRegex = /(frame=\s*)(\d+)(\s*fps=\s*)(\d+(.\d+)?)(\s*q=)(-?(\d+.?)+)(\s*(L)?size=\s*)(\d+(kB|mB|b))(\s*time=\s*)(\d{2}:\d{2}:\d{2}.\d{2})(\s*bitrate=\s*)(\d+(.\d+)?(\w+(\/s)?)(\s*speed=)(\d+.\d+x))/g 27 | const timeRegex = /(\d{2}):(\d{2}):(\d{2}.\d{2})/ 28 | 29 | const renderProcess: ChildProcess = spawn( 30 | (this.config.paths && this.config.paths.ffmpeg) || process.platform === 'win32' 31 | ? 'ffmpeg.exe' 32 | : 'ffmpeg', 33 | args, 34 | { shell: true } 35 | ) 36 | 37 | const fileDuration = step.analysis.info.format && step.analysis.info.format.duration 38 | if (fileDuration) { 39 | renderProcess.stderr.on('data', (data: any) => { 40 | const stringData = data.toString() 41 | // console.log(stringData) 42 | let res: RegExpExecArray | null 43 | while ((res = progressReportRegex.exec(stringData)) !== null) { 44 | // console.log(step.output, res[14]) 45 | const time = timeRegex.exec(res[14]) 46 | if (time) { 47 | const t = Number(time[1]) * 3600 + Number(time[2]) * 60 + Number(time[3]) 48 | step.renderProgress(t / Number(fileDuration)) 49 | } 50 | } 51 | }) 52 | } 53 | 54 | let resolver: () => void 55 | let rejecter: (err: Error) => void 56 | 57 | const metaPromise = new Promise((resolve, reject) => { 58 | resolver = resolve 59 | rejecter = reject 60 | }) 61 | 62 | renderProcess.on('close', (code) => { 63 | if (code === 0) { 64 | resolver() 65 | } else { 66 | rejecter(new Error(`Worker: renderer (${step.output}): FFmpeg failed with code ${code}`)) 67 | } 68 | }) 69 | 70 | return metaPromise 71 | } 72 | 73 | private _getProcessArgs(step: RenderWorkstep) { 74 | const args = ['-y', '-i', `"${step.input}"`] 75 | 76 | if (step.encoderConfig.custom) { 77 | let stringHasMatchData: boolean = false 78 | // data available for use in handlebars 79 | const customEncoderMatchData: { [key: string]: string } = { 80 | ...step.outputParse, 81 | postFix: step.encoderConfig.postFix, 82 | extension: step.encoderConfig.extension 83 | ? step.encoderConfig.extension 84 | : step.outputParse.ext, 85 | date: new Date().toISOString().slice(0, 10) 86 | } 87 | // replace handlebars with data 88 | const customEncoderString = step.encoderConfig.custom.replace( 89 | /\{\{([^}]+)\}\}/g, 90 | (match: string) => { 91 | match = match.slice(2, -2) 92 | if (!customEncoderMatchData[match]) return '{{' + match + '}}' 93 | stringHasMatchData = true 94 | return customEncoderMatchData[match] 95 | } 96 | ) 97 | 98 | if (stringHasMatchData) { 99 | // handlebars are used. do not append the output file name 100 | args.push(customEncoderString) 101 | } else { 102 | // handlebars are not used. append the output file name 103 | args.push(step.encoderConfig.custom) 104 | args.push(`"${step.output}"`) 105 | } 106 | return args 107 | } 108 | 109 | const discard = step.encoderConfig.discard || {} 110 | 111 | if (discard.video) { 112 | args.push('-vn') 113 | } else { 114 | if (step.encoderConfig.videoEncoder) { 115 | const videoConfig = step.encoderConfig.videoEncoder 116 | 117 | args.push('-codec:v', videoConfig.encoder || 'libx264') 118 | 119 | if (videoConfig.encoderOptions) args.push(...videoConfig.encoderOptions) 120 | else args.push('-crf', '18') 121 | 122 | const videoFilter: Array = [] 123 | const inputFieldOrder = step.analysis.info.field_order 124 | 125 | if (step.encoderConfig.format) { 126 | const f = step.encoderConfig.format 127 | if (f.width || f.height) { 128 | videoFilter.push( 129 | `scale=w=${f.width || '-1'}:h=${f.height || -1}:interl=${ 130 | inputFieldOrder !== FieldOrder.Progressive ? 1 : 0 131 | }:${f.width && f.height ? 'force_original_aspect_ratio=decrease' : ''}` 132 | ) 133 | if (f.width && f.height && f.width > 0 && f.height > 0) { 134 | videoFilter.push(`pad=${f.width || '-1'}:${f.height}:-1:-1`) 135 | } 136 | } 137 | if (inputFieldOrder !== FieldOrder.Progressive && f.interlaced === undefined) { 138 | // input is interlaced, output is progressive 139 | 140 | // export 1 frame per frame or 1 frame per field: 141 | const mode = (f.frameRate || 25) >= 50 ? 1 : 0 142 | // if we know fieldorder instruct filter, otherwise autodetect: 143 | const parity = 144 | inputFieldOrder === FieldOrder.BFF ? 1 : inputFieldOrder === FieldOrder.TFF ? 0 : -1 145 | // if we know fieldorder always deinterlace, otherwise autodetect: 146 | const deint = inputFieldOrder && inputFieldOrder !== FieldOrder.Unknown ? 0 : -1 147 | 148 | videoFilter.push(`bwdif=mode=${mode}:parity=${parity}:deint=${deint}`) 149 | } 150 | if (f.interlaced) { 151 | // output is interlaced 152 | if (inputFieldOrder) { 153 | // input has metadata 154 | if (inputFieldOrder !== (f.interlaced as unknown)) { 155 | // input !== output 156 | if (inputFieldOrder === FieldOrder.Progressive) { 157 | // input is progressive 158 | const modes: { [key: string]: string } = { 159 | [FieldOrder.TFF]: 'interleave_top', 160 | [FieldOrder.BFF]: 'interleave_bottom' 161 | } 162 | const mode = modes[f.interlaced] as string 163 | 164 | videoFilter.push('fps=' + (f.frameRate || 25) * 2) // make sure input has appropriate amount of frames 165 | videoFilter.push('tinterlace=mode=' + mode) 166 | } 167 | } 168 | } else { 169 | // TODO - is there a ffmpeg filter that can interlace based on decoder field metadata? 170 | } 171 | 172 | // set fieldorder (this will correctly set tff/bff) 173 | videoFilter.push(`fieldorder=${f.interlaced}`) 174 | } else if (f.frameRate) { 175 | videoFilter.push(`fps=${f.frameRate || '25'}`) 176 | } 177 | // note that input needs a color space to be set for use to do useful things 178 | const hasInpColorSpace = step.analysis.info.colorSpace 179 | if (hasInpColorSpace && f.colorspace) { 180 | videoFilter.push(`colorspace=${f.colorspace}`) 181 | } else if (hasInpColorSpace && f.height) { 182 | const cSpace = f.height <= 576 ? 'bt601-6-625' : 'bt709' 183 | videoFilter.push(`colorspace=${cSpace}`) 184 | } 185 | } 186 | 187 | if (videoFilter.length) args.push('-filter:v', videoFilter.join(',')) 188 | } else { 189 | args.push('-codec:v', 'copy') 190 | } 191 | } 192 | 193 | if (discard.audio) { 194 | args.push('-an') 195 | } else { 196 | if (step.encoderConfig.audioEncoder) { 197 | const audioConfig = step.encoderConfig.audioEncoder 198 | 199 | args.push('-codec:a', audioConfig.encoder || 'aac') 200 | 201 | if (audioConfig.encoderOptions) args.push(...audioConfig.encoderOptions) 202 | 203 | let audioFilter = '' 204 | 205 | if (step.encoderConfig.loudness) { 206 | let measured = '' 207 | if (step.analysis.info.loudness) { 208 | // pass in measure values 209 | const loudness = step.analysis.info.loudness 210 | measured = 211 | `measured_i=${loudness.integrated}:` + 212 | `measured_lra=${loudness.LRA}:` + 213 | `measured_tp=${loudness.truePeak}:` + 214 | `measured_thresh=${loudness.threshold}:` 215 | } 216 | const lConfig = step.encoderConfig.loudness 217 | audioFilter += `loudnorm=${measured}i=${lConfig.integrated || -23}:lra=${lConfig.LRA || 218 | 13}:tp=${lConfig.truePeak || -1}:dual_mono=${lConfig.dualMono || 'false'}` 219 | } 220 | 221 | if (audioFilter) args.push('-filter:a', audioFilter) 222 | 223 | if (step.encoderConfig.format && step.encoderConfig.format.audioRate) { 224 | args.push('-ar', step.encoderConfig.format.audioRate) 225 | } else if (step.encoderConfig.loudness) { 226 | args.push('-ar', '48k') 227 | } 228 | } else { 229 | args.push('-codec:a', 'copy') 230 | } 231 | } 232 | 233 | if (discard.subtitle) { 234 | args.push('-sn') 235 | } 236 | 237 | if (discard.data) { 238 | args.push('-dn') 239 | } 240 | 241 | if (step.encoderConfig.format && step.encoderConfig.format.format) { 242 | args.push('-f', step.encoderConfig.format.format) 243 | } 244 | 245 | // pass the interlacing flags 246 | if ( 247 | !discard.video && 248 | step.encoderConfig.videoEncoder && 249 | step.encoderConfig.format && 250 | step.encoderConfig.format.interlaced 251 | ) { 252 | args.push('-flags', '+ildct+ilme') 253 | } 254 | 255 | args.push(`"${step.output}"`) 256 | 257 | return args 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /src/background/analyzer.ts: -------------------------------------------------------------------------------- 1 | // analyzer bits 2 | import { noTryAsync } from 'no-try' 3 | import { exec, spawn, ChildProcess } from 'child_process' 4 | import { WorkStep } from './workStep' 5 | import { 6 | MediaInfo, 7 | Analysis, 8 | Anomaly, 9 | LoudnessInfo, 10 | FieldOrder, 11 | Anomalies 12 | } from '@/types/mediaInfo' 13 | import { literal } from './util' 14 | import { Config } from '@/types/config' 15 | import { EventEmitter } from 'events' 16 | 17 | export class Analyzer extends EventEmitter { 18 | config: Config 19 | logger: any = console 20 | 21 | private _steps: number 22 | 23 | constructor(config: Config) { 24 | super() 25 | this.config = config 26 | 27 | this._steps = 1 // for basic analysis 28 | if (this.config.interlaced) this._steps++ 29 | if ( 30 | this.config.blackFrames || 31 | this.config.freezeFrames || 32 | this.config.borders || 33 | this.config.silence 34 | ) 35 | this._steps++ 36 | if (this.config.loudness) this._steps++ 37 | } 38 | 39 | async analyzeFile(step: WorkStep): Promise { 40 | step.progressSteps += this._steps 41 | 42 | const info = await this.analyzeFormat(step) 43 | step.completed() 44 | 45 | if (step.failed || info === null) { 46 | throw new Error('Step failed to run') 47 | } 48 | 49 | const analysis: Analysis = { 50 | info, 51 | anomalies: {} 52 | } 53 | 54 | const { color_space } = (info.streams || []).find((s) => s.color_space) || {} 55 | analysis.info.colorSpace = color_space 56 | 57 | if (this.config.interlaced) { 58 | analysis.info.field_order = await this.analyzeInterlacing(step) 59 | 60 | step.completed() 61 | } 62 | 63 | const { width, height } = (info.streams || []).find((s) => s.width) || {} 64 | 65 | if ( 66 | this.config.blackFrames || 67 | this.config.freezeFrames || 68 | this.config.borders || 69 | this.config.silence 70 | ) { 71 | analysis.anomalies = await this.analyzeAnomalies( 72 | step, 73 | Number(analysis.info.format!.duration || 0), 74 | width, 75 | height 76 | ) 77 | 78 | step.completed() 79 | } 80 | 81 | if (this.config.loudness) { 82 | analysis.info.loudness = (await this.analyzeLoudness(step)) || undefined 83 | 84 | step.completed() 85 | } 86 | 87 | return analysis 88 | } 89 | 90 | async analyzeFormat(step: WorkStep): Promise { 91 | const args = [ 92 | (this.config.paths && this.config.paths.ffprobe) || process.platform === 'win32' 93 | ? 'ffprobe.exe' 94 | : 'ffprobe', 95 | '-hide_banner', 96 | '-i', 97 | `"${step.input}"`, 98 | '-show_streams', 99 | '-show_format', 100 | '-print_format', 101 | 'json' 102 | ] 103 | 104 | const { result: probeData, error: execError } = await noTryAsync( 105 | () => 106 | new Promise((resolve, reject) => { 107 | exec(args.join(' '), (err, stdout, stderr) => { 108 | this.logger.debug(`Worker: metadata generate: output (stdout, stderr)`, stdout, stderr) 109 | if (err) { 110 | return reject(err) 111 | } 112 | const json: any = JSON.parse(stdout) 113 | if (!json.streams || !json.streams[0]) { 114 | return reject(new Error('not media')) 115 | } 116 | resolve(json) 117 | }) 118 | }) 119 | ) 120 | 121 | if (execError) { 122 | step.failStep(`External process to generate metadata failed`, execError) 123 | return null 124 | } 125 | 126 | this.logger.info(`Worker: metadata generate: generated metadata for "${step.input}"`) 127 | this.logger.debug(`Worker: metadata generate: generated metadata details`, probeData) 128 | 129 | const newInfo = literal({ 130 | name: step.input, 131 | 132 | streams: probeData.streams.map((s: any) => ({ 133 | codec: { 134 | long_name: s.codec_long_name, 135 | type: s.codec_type, 136 | time_base: s.codec_time_base, 137 | tag_string: s.codec_tag_string, 138 | is_avc: s.is_avc 139 | }, 140 | 141 | // Video 142 | width: s.width, 143 | height: s.height, 144 | sample_aspect_ratio: s.sample_aspect_ratio, 145 | display_aspect_ratio: s.display_aspect_ratio, 146 | pix_fmt: s.pix_fmt, 147 | bits_per_raw_sample: s.bits_per_raw_sample, 148 | 149 | // Audio 150 | sample_fmt: s.sample_fmt, 151 | sample_rate: s.sample_rate, 152 | channels: s.channels, 153 | channel_layout: s.channel_layout, 154 | bits_per_sample: s.bits_per_sample, 155 | 156 | // Common 157 | time_base: s.time_base, 158 | start_time: s.start_time, 159 | duration_ts: s.duration_ts, 160 | duration: s.duration, 161 | 162 | bit_rate: s.bit_rate, 163 | max_bit_rate: s.max_bit_rate, 164 | nb_frames: s.nb_frames 165 | })), 166 | format: { 167 | name: probeData.format.format_name, 168 | long_name: probeData.format.format_long_name, 169 | // size: probeData.format.time, carried at a higher level 170 | 171 | start_time: probeData.format.start_time, 172 | duration: probeData.format.duration, 173 | bit_rate: probeData.format.bit_rate, 174 | max_bit_rate: probeData.format.max_bit_rate 175 | } 176 | }) 177 | 178 | return newInfo 179 | } 180 | 181 | async analyzeInterlacing(step: WorkStep): Promise { 182 | const args = [ 183 | // TODO (perf) Low priority process? 184 | (this.config.paths && this.config.paths.ffmpeg) || process.platform === 'win32' 185 | ? 'ffmpeg.exe' 186 | : 'ffmpeg', 187 | '-hide_banner', 188 | '-filter:v', 189 | 'idet', 190 | '-frames:v', 191 | (this.config.interlaced && this.config.interlaced.analyzeTime) || 200, 192 | '-an', 193 | '-f', 194 | 'rawvideo', 195 | '-y', 196 | process.platform === 'win32' ? 'NUL' : '/dev/null', 197 | '-i', 198 | `"${step.input}"` 199 | ] 200 | 201 | const { error: execError, result } = await noTryAsync( 202 | () => 203 | new Promise((resolve, reject) => { 204 | exec(args.join(' '), (err, stdout, stderr) => { 205 | this.logger.debug(`Worker: field order detect: output (stdout, stderr)`, stdout, stderr) 206 | if (err) { 207 | return reject(err) 208 | } 209 | resolve(stderr) 210 | }) 211 | }) 212 | ) 213 | if (execError) { 214 | this.logger.error( 215 | `External process to detect field order for "${step.input}" failed`, 216 | execError 217 | ) 218 | return FieldOrder.Unknown 219 | } 220 | this.logger.info(`Worker: field order detect: generated field order for "${step.input}"`) 221 | 222 | const fieldRegex = /Multi frame detection: TFF:\s+(\d+)\s+BFF:\s+(\d+)\s+Progressive:\s+(\d+)/ 223 | const res = fieldRegex.exec(result) 224 | if (res === null) { 225 | return FieldOrder.Unknown 226 | } 227 | 228 | const tff = parseInt(res[1]) 229 | const bff = parseInt(res[2]) 230 | const fieldOrder = 231 | tff <= 10 && bff <= 10 ? FieldOrder.Progressive : tff > bff ? FieldOrder.TFF : FieldOrder.BFF 232 | 233 | return fieldOrder 234 | } 235 | 236 | analyzeAnomalies( 237 | step: WorkStep, 238 | duration: number, 239 | expectedWidth?: number, 240 | expectedHeight?: number 241 | ): Promise { 242 | const blackDetectRegex = /(black_start:)(\d+(.\d+)?)( black_end:)(\d+(.\d+)?)( black_duration:)(\d+(.\d+))?/g 243 | const freezeDetectStart = /(lavfi\.freezedetect\.freeze_start: )(\d+(.\d+)?)/g 244 | const freezeDetectDuration = /(lavfi\.freezedetect\.freeze_duration: )(\d+(.\d+)?)/g 245 | const freezeDetectEnd = /(lavfi\.freezedetect\.freeze_end: )(\d+(.\d+)?)/g 246 | const cropDetectReport = /(x1:)(\d+)( x2:)(\d+)( y1):(\d+)( y2:)(\d+)( w:)(\d+)( h:)(\d+)( x:)(\d+)( y:)(\d+)( pts:)(\d+(.\d+)?)( t:)(\d+(.\d+)?)( crop=)(\d+:\d+:\d+:\d+)/g 247 | const silenceDetectStart = /(silence_start: )((-)?\d+(.\d+)?)/g 248 | const silenceDetectEnd = /(silence_end: )(\d+(.\d+)?)( \| silence_duration: )(\d+(.\d+)?)/g 249 | const anomalies: Anomalies = { 250 | blacks: [], 251 | freezes: [], 252 | silences: [] 253 | } 254 | const cropped: Array = [] 255 | 256 | let vFilterString = '' 257 | if (this.config.blackFrames) { 258 | vFilterString += 259 | `blackdetect=d=${this.config.blackFrames.blackDuration || '2.0'}:` + 260 | `pic_th=${this.config.blackFrames.blackRatio || 0.98}:` + 261 | `pix_th=${this.config.blackFrames.blackThreshold || 0.1}` 262 | } 263 | 264 | if (this.config.borders && expectedHeight && expectedWidth) { 265 | if (vFilterString) { 266 | vFilterString += ',' 267 | } 268 | vFilterString += 269 | `cropdetect=round=${this.config.borders.round || 8}:` + 270 | `reset=${this.config.borders.reset || '0'}` 271 | } 272 | 273 | if (this.config.freezeFrames) { 274 | if (vFilterString) { 275 | vFilterString += ',' 276 | } 277 | vFilterString += 278 | `freezedetect=n=${this.config.freezeFrames.freezeNoise || 0.001}:` + 279 | `d=${this.config.freezeFrames.freezeDuration || '2s'}` 280 | } 281 | 282 | let aFilterString = '' 283 | if (this.config.silence) { 284 | aFilterString += `silencedetect=n=${this.config.silence.noise || '-60dB'}:d=${this.config 285 | .silence.duration || '2'}` // :m=${this.config.silence.mono || 0}` 286 | } 287 | 288 | const args = [ 289 | '-hide_banner', 290 | '-i', 291 | `"${step.input}"`, 292 | vFilterString && '-filter:v', 293 | vFilterString, 294 | aFilterString && '-filter:a', 295 | aFilterString, 296 | '-f', 297 | 'null', 298 | '-' 299 | ] 300 | 301 | console.log(args) 302 | 303 | const infoProcess: ChildProcess = spawn( 304 | (this.config.paths && this.config.paths.ffmpeg) || process.platform === 'win32' 305 | ? 'ffmpeg.exe' 306 | : 'ffmpeg', 307 | args, 308 | { shell: true } 309 | ) 310 | 311 | let curCrop: Anomaly | undefined = undefined 312 | infoProcess.stderr.on('data', (data: any) => { 313 | const stringData = data.toString() 314 | if (typeof stringData !== 'string') return 315 | const frameMatch = stringData.match(/^frame= +\d+/) 316 | if (frameMatch) { 317 | // currentFrame = Number(frameMatch[0].replace('frame=', '')) 318 | return 319 | } 320 | 321 | let res: RegExpExecArray | null 322 | 323 | while ((res = blackDetectRegex.exec(stringData)) !== null) { 324 | anomalies.blacks!.push( 325 | literal({ 326 | start: parseFloat(res[2]), 327 | duration: parseFloat(res[8]), 328 | end: parseFloat(res[5]) 329 | }) 330 | ) 331 | } 332 | 333 | while ((res = freezeDetectStart.exec(stringData)) !== null) { 334 | anomalies.freezes!.push( 335 | literal({ 336 | start: parseFloat(res[2]), 337 | duration: 0.0, 338 | end: 0.0 339 | }) 340 | ) 341 | } 342 | 343 | let i = 0 344 | while ((res = freezeDetectDuration.exec(stringData)) !== null) { 345 | anomalies.freezes![i++].duration = parseFloat(res[2]) 346 | } 347 | 348 | i = 0 349 | while ((res = freezeDetectEnd.exec(stringData)) !== null) { 350 | anomalies.freezes![i++].end = parseFloat(res[2]) 351 | } 352 | 353 | while ((res = cropDetectReport.exec(stringData)) !== null) { 354 | // just a quick hardcoded thing 355 | const w = expectedWidth || 1920 356 | const h = expectedHeight || 1080 357 | 358 | if (Number(res[10]) !== w || Number(res[12]) !== h) { 359 | const t = Number(res[21]) 360 | if (curCrop) { 361 | if (curCrop.end - t > 0.4 || t === duration) { 362 | // hardcoded 25p 363 | curCrop.duration = curCrop.end - curCrop.start 364 | cropped.push(curCrop) 365 | curCrop = undefined 366 | } else { 367 | curCrop.end = t 368 | } 369 | } 370 | if (!curCrop) { 371 | curCrop = { 372 | start: t, 373 | duration: 0.4, 374 | end: t + 0.4 375 | } 376 | } 377 | } 378 | } 379 | 380 | while ((res = silenceDetectStart.exec(stringData)) !== null) { 381 | anomalies.silences!.push( 382 | literal({ 383 | start: parseFloat(res[2]), 384 | duration: 0, 385 | end: 0 386 | }) 387 | ) 388 | } 389 | 390 | let s = 0 391 | while ((res = silenceDetectEnd.exec(stringData)) !== null) { 392 | anomalies.silences![s].end = parseFloat(res[2]) 393 | anomalies.silences![s++].duration = parseFloat(res[5]) 394 | } 395 | }) 396 | 397 | let resolver: (m: Anomalies) => void 398 | let rejecter: (err: Error) => void 399 | 400 | const metaPromise = new Promise((resolve, reject) => { 401 | resolver = resolve 402 | rejecter = reject 403 | }) 404 | 405 | infoProcess.on('close', (code) => { 406 | if (code === 0) { 407 | // success 408 | 409 | // if freeze frame is the end of video, it is not detected fully 410 | if ( 411 | anomalies.freezes![anomalies.freezes!.length - 1] && 412 | !anomalies.freezes![anomalies.freezes!.length - 1].end 413 | ) { 414 | anomalies.freezes![anomalies.freezes!.length - 1].end = duration 415 | anomalies.freezes![anomalies.freezes!.length - 1].duration = 416 | duration - anomalies.freezes![anomalies.freezes!.length - 1].start 417 | } 418 | // if silence is the end of video, it is not detected fully 419 | if ( 420 | anomalies.silences![anomalies.silences!.length - 1] && 421 | !anomalies.silences![anomalies.silences!.length - 1].end 422 | ) { 423 | anomalies.silences![anomalies.silences!.length - 1].end = duration 424 | anomalies.silences![anomalies.silences!.length - 1].duration = 425 | duration - anomalies.silences![anomalies.silences!.length - 1].start 426 | } 427 | 428 | this.logger.debug( 429 | `Worker: get anomalies: completed metadata analysis: freezes ${ 430 | anomalies.freezes!.length 431 | }, blacks ${anomalies.blacks!.length}` 432 | ) 433 | 434 | // end crop 435 | if (curCrop) { 436 | curCrop.duration = curCrop.end - curCrop.start 437 | cropped.push(curCrop) 438 | } 439 | 440 | anomalies.borders = cropped 441 | resolver(anomalies) 442 | } else { 443 | this.logger.error(`Worker: get anomalies: FFmpeg failed with code ${code}`) 444 | rejecter(new Error(`Worker: get anomalies: FFmpeg failed with code ${code}`)) 445 | } 446 | }) 447 | 448 | return metaPromise 449 | } 450 | 451 | async analyzeBlackFrames(step: WorkStep): Promise> { 452 | return [] 453 | } 454 | 455 | async analyzeFreezes(step: WorkStep): Promise> { 456 | return [] 457 | } 458 | 459 | async analyzeBorders(step: WorkStep): Promise> { 460 | return [] 461 | } 462 | 463 | async analyzeSilence(step: WorkStep): Promise> { 464 | return [] 465 | } 466 | 467 | async analyzeLoudness(step: WorkStep): Promise { 468 | const args = [ 469 | (this.config.paths && this.config.paths.ffmpeg) || process.platform === 'win32' 470 | ? 'ffmpeg.exe' 471 | : 'ffmpeg', 472 | '-hide_banner', 473 | '-i', 474 | `"${step.input}"`, 475 | '-filter:a', 476 | 'loudnorm=print_format=json', 477 | '-vn', 478 | '-f', 479 | 'null', 480 | '-threads', 481 | '1', 482 | '-' 483 | ] 484 | 485 | const { result: probeData, error: execError } = await noTryAsync( 486 | () => 487 | new Promise((resolve, reject) => { 488 | exec(args.join(' '), (err, stdout, stderr) => { 489 | this.logger.debug(`Worker: metadata generate: output (stdout, stderr)`, stdout, stderr) 490 | if (err) { 491 | return reject(err) 492 | } 493 | 494 | const lines = stderr.split(process.platform === 'win32' ? '\r\n' : '\n') 495 | const s = lines.splice(-13, 12).join('\n') 496 | const json: any = JSON.parse(s) 497 | resolve(json) 498 | }) 499 | }) 500 | ) 501 | 502 | if (execError) { 503 | step.failStep(`External process to generate loudness info failed`, execError) 504 | return null 505 | } 506 | 507 | this.logger.info(`Worker: loudness info generate: generated loudness info for "${step.input}"`) 508 | this.logger.debug(`Worker: loudness info generate: generated loudness info details`, probeData) 509 | 510 | const loudness: LoudnessInfo = { 511 | integrated: Number(probeData.input_i), 512 | LRA: Number(probeData.input_lra), 513 | truePeak: Number(probeData.input_tp), 514 | threshold: Number(probeData.input_thresh) 515 | } 516 | 517 | return loudness 518 | } 519 | } 520 | --------------------------------------------------------------------------------