Skip to content

Commit ffb19e1

Browse files
authored
Add extraction wizard (#4)
1 parent 13fab60 commit ffb19e1

17 files changed

Lines changed: 564 additions & 59 deletions

angular.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"src/assets"
2121
],
2222
"styles": [
23+
"./node_modules/@angular/material/prebuilt-themes/deeppurple-amber.css",
2324
"src/styles.scss"
2425
],
2526
"scripts": [],
@@ -170,4 +171,4 @@
170171
"cli": {
171172
"analytics": "08501833-8ed2-4495-9ec6-b2c0cd10afca"
172173
}
173-
}
174+
}

extraction/video.ts

Lines changed: 88 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { BehaviorSubject, Observable } from 'rxjs';
66
import { FfpathsConfig } from './ffpaths';
77
import * as moment from 'moment';
88
import * as os from 'os';
9+
import { cursorTo } from 'readline';
910

1011
type Statuses =
1112
| 'NOT_STARTED'
@@ -19,6 +20,7 @@ export interface ExtractionStatus {
1920
uri: string;
2021
phase: Statuses;
2122
percentage: number;
23+
debug?: unknown;
2224
}
2325

2426
export interface Interval {
@@ -42,7 +44,7 @@ export class Video {
4244

4345
getInfo(): Promise<ffmpeg.FfprobeData> {
4446
return new Promise((resolve, reject) => {
45-
ffmpeg.ffprobe(this.videoPath, (err, data) => {
47+
ffmpeg.ffprobe(this.videoPath, ['-show_chapters'], (err, data) => {
4648
if (data) resolve(data);
4749
if (err) reject(err);
4850
});
@@ -56,7 +58,9 @@ export class Video {
5658
fs.mkdirSync(this.scratchPath, { recursive: true });
5759
// TODO: Is there a better way to find the "desktop" folder?
5860
const outputFolder = path.join(os.homedir(), 'Desktop', 'Dialog');
59-
fs.mkdirSync(outputFolder);
61+
if (!fs.existsSync(outputFolder)) {
62+
fs.mkdirSync(outputFolder);
63+
}
6064
// TODO: This somehow throws a user-visible error but does not stop execution.
6165
// Figure out how to catch this and prevent moving forward.
6266
this.stream = fs.createWriteStream(
@@ -68,6 +72,43 @@ export class Video {
6872
// Note: This doesn't throw an error when it fails (for example, with recursive: false)...
6973
fs.rmdirSync(this.scratchPath, { recursive: true });
7074

75+
this.extractionProgress.next({
76+
uri: this.videoPath, phase: 'DONE',
77+
percentage: 100,
78+
});
79+
console.log('Extraction complete.');
80+
} catch (e) {
81+
this.extractionProgress.next({
82+
uri: this.videoPath, phase: 'ERROR',
83+
percentage: 100,
84+
debug: e,
85+
});
86+
throw e;
87+
}
88+
}
89+
90+
async extractDialogNew(config: any): Promise<void> {
91+
try {
92+
this.scratchPath = fs.mkdtempSync(path.join(os.tmpdir(), `${path.basename(this.videoPath, path.extname(this.videoPath))}-`));
93+
console.log('Scratch path', this.scratchPath);
94+
fs.mkdirSync(this.scratchPath, { recursive: true });
95+
// TODO: Is there a better way to find the "desktop" folder?
96+
const outputFolder = path.join(os.homedir(), 'Desktop', 'Dialog');
97+
if (!fs.existsSync(outputFolder)) {
98+
fs.mkdirSync(outputFolder);
99+
}
100+
// TODO: This somehow throws a user-visible error but does not stop execution.
101+
// Figure out how to catch this and prevent moving forward.
102+
this.stream = fs.createWriteStream(
103+
`${path.join(outputFolder, path.basename(this.videoPath, path.extname(this.videoPath)))}.mp3`);
104+
await this.extractSubtitles(config.subtitleStream);
105+
const intervals = await this.getSubtitleIntervals();
106+
let combined = this.combineIntervals(intervals);
107+
combined = await this.subtractChapters(combined, config.ignoredChapters);
108+
await this.extractAudio(combined, config.audioStream);
109+
// Note: This doesn't throw an error when it fails (for example, with recursive: false)...
110+
fs.rmdirSync(this.scratchPath, { recursive: true });
111+
71112
this.extractionProgress.next({
72113
uri: this.videoPath, phase: 'DONE',
73114
percentage: 100,
@@ -82,6 +123,43 @@ export class Video {
82123
}
83124
}
84125

126+
private async subtractChapters(combined: Interval[], chapters: string[]): Promise<Interval[]> {
127+
const info = await this.getInfo();
128+
129+
const chapterIntervals: Interval[] = [];
130+
chapters.forEach(chap =>
131+
info.chapters.filter(c => c['TAG:title'] === chap)
132+
.forEach(c => chapterIntervals.push({
133+
start: this.formalize(moment.duration(c.start_time, 'seconds')),
134+
end: this.formalize(moment.duration(c.end_time, 'seconds')),
135+
})));
136+
137+
let out: Interval[] = [...combined];
138+
139+
for (const chapter of chapterIntervals) {
140+
const revision = [];
141+
for (const ivl of out) {
142+
const cur: Interval = {start: ivl.start, end: ivl.end};
143+
if (cur.start > chapter.start && cur.start < chapter.end) {
144+
cur.start = chapter.end;
145+
}
146+
if (cur.end > chapter.start && cur.end < chapter.end) {
147+
cur.end = chapter.start;
148+
}
149+
if (cur.start < cur.end) {
150+
revision.push(cur);
151+
}
152+
}
153+
out = revision;
154+
}
155+
156+
return out;
157+
}
158+
159+
private formalize(duration: moment.Duration): string {
160+
return `${`${duration.hours()}`.padStart(2, '0')}:${`${duration.minutes()}`.padStart(2, '0')}:${`${duration.seconds()}`.padStart(2, '0')}.${`${duration.milliseconds()}`.padStart(3, '0')}`
161+
}
162+
85163
private async toPromise(command: ffmpeg.FfmpegCommand, finish: (command: ffmpeg.FfmpegCommand) => void): Promise<any> {
86164
return new Promise((resolve, reject) => {
87165
finish(
@@ -97,12 +175,14 @@ export class Video {
97175
}
98176

99177
/** Synchronously extracts segments. */
100-
private async extractAudio(intervals: Interval[]) {
101-
for (let i = 0, max = intervals.length; i < max; i++) {//intervals.length; i++) {
178+
private async extractAudio(intervals: Interval[], stream?: ffmpeg.FfprobeStream) {
179+
const track = stream ? stream.index : 2; // TODO: get rid of 2
180+
181+
for (let i = 0, max = intervals.length; i < max; i++) {
102182
const interval = intervals[i];
103183
const command = ffmpeg(this.videoPath)
104184
.noVideo()
105-
.outputOption(`-ss`, `${interval.start}`, `-to`, `${interval.end}`)//, "-q:a", "0", "-map", "a")
185+
.outputOption(`-ss`, `${interval.start}`, `-to`, `${interval.end}`, '-map', `0:${track}`)//, "-q:a", "0", "-map", "a")
106186
.audioBitrate('128k')
107187
.audioCodec('libmp3lame')
108188
.format('mp3')
@@ -116,9 +196,10 @@ export class Video {
116196
}
117197
}
118198

119-
private async extractSubtitles() {
199+
private async extractSubtitles(stream?: ffmpeg.FfprobeStream) {
200+
const track = stream ? stream.index : 2; // TODO: get rid of 2
120201
const command = ffmpeg(this.videoPath)
121-
.outputOption('-map 0:2')
202+
.outputOption(`-map 0:${track}`)
122203
.saveToFile(path.join(this.scratchPath, 'subs.srt'))
123204
.on('progress', progress => {
124205
this.extractionProgress.next({ uri: this.videoPath, phase: 'EXTRACTING_SUBTITLES', percentage: progress.percent });

main.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { app, BrowserWindow, dialog, ipcMain, screen } from 'electron';
44
import * as path from 'path';
55
import * as url from 'url';
66
import { Video } from './extraction/video';
7+
import { FfprobeData } from 'fluent-ffmpeg';
78

89
let win: BrowserWindow = null;
910
const args = process.argv.slice(1),
@@ -58,7 +59,7 @@ function createWindow(): BrowserWindow {
5859
const selectFiles = async (event: Electron.IpcMainEvent) => {
5960
const value = await dialog.showOpenDialog({
6061
properties: ['openFile', 'multiSelections'],
61-
filters: [{ name: 'videosOnly', extensions: ['mkv'] }],
62+
// filters: [{ name: 'videosOnly', extensions: ['mkv'] }],
6263
});
6364
if (value.canceled) {
6465
// User hit "cancel" on the file selector.
@@ -80,26 +81,30 @@ ipcMain.on('select-files', (event) => {
8081
});
8182
});
8283

83-
const extractDialog = async (event: Electron.IpcMainEvent, vidPaths: string[]) => {
84-
for (let i = 0; i < vidPaths.length; i++) {
85-
const vidPath = vidPaths[i];
86-
console.log('Extracting for file', vidPath);
87-
const v = new Video(vidPath, path.join(__dirname, '.tmp/'), {
84+
const extractDialog = async (event: Electron.IpcMainEvent, vidConfigs: any[]) => {
85+
console.log('VidConfigs!', vidConfigs);
86+
87+
for (let i = 0; i < vidConfigs.length; i++) {
88+
const vidConfig = vidConfigs[i];
89+
90+
const myUri = vidConfig.video.ffprobeData.format.filename; //(vidConfig.video.ffprobeData.format as FfprobeData).format.filename;
91+
console.log('Extracting for file', myUri);
92+
const v = new Video(myUri, path.join(__dirname, '.tmp/'), {
8893
ffmpeg: ffmpeg.path.replace('app.asar', 'app.asar.unpacked'),
8994
ffprobe: ffprobe.path.replace('app.asar', 'app.asar.unpacked'),
9095
});
9196
const sub = v.getProgress().subscribe(status => {
9297
event.sender.send('progress-update', status);
9398
});
94-
await v.extractDialog();
99+
await v.extractDialogNew(vidConfig);
95100
sub.unsubscribe();
96101
}
97102
};
98103

99-
ipcMain.on('extract-dialog', (event, vidPaths) => {
100-
extractDialog(event, vidPaths)
104+
ipcMain.on('extract-dialog-new', (event, vidConfigs) => {
105+
extractDialog(event, vidConfigs)
101106
.catch(reason => {
102-
event.sender.send('error', `Error occurred while extracting dialog: ${JSON.stringify(reason)}`);
107+
event.sender.send('error', `Error occurred while extracting dialog: ${reason as string}`);
103108
});
104109
});
105110

package-lock.json

Lines changed: 34 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,11 @@
9696
"node": ">=10.13.0"
9797
},
9898
"dependencies": {
99+
"@angular/animations": "^10.0.0 || ^11.0.0-0",
100+
"@angular/cdk": "10.2.0",
101+
"@angular/forms": "^10.0.0 || ^11.0.0-0",
99102
"@angular/localize": "10.0.14",
103+
"@angular/material": "10.2.0",
100104
"@ffmpeg-installer/ffmpeg": "1.0.20",
101105
"@ffprobe-installer/ffprobe": "1.1.0",
102106
"@ng-bootstrap/ng-bootstrap": "7.0.0",

src/app/app-routing.module.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
11
import { NgModule } from '@angular/core';
2-
import { Routes, RouterModule } from '@angular/router';
3-
import { PageNotFoundComponent } from './shared/components';
4-
5-
import { HomeRoutingModule } from './home/home-routing.module';
2+
import { RouterModule, Routes } from '@angular/router';
63
import { DetailRoutingModule } from './detail/detail-routing.module';
4+
import { HomeRoutingModule } from './home/home-routing.module';
5+
import { PageNotFoundComponent } from './shared/components';
6+
import { WizardComponent } from './wizard/wizard.component';
77

88
const routes: Routes = [
99
{
1010
path: '',
1111
redirectTo: 'home',
12-
pathMatch: 'full'
12+
pathMatch: 'full',
13+
},
14+
{
15+
path: 'wizard',
16+
component: WizardComponent,
1317
},
1418
{
1519
path: '**',
16-
component: PageNotFoundComponent
17-
}
20+
component: PageNotFoundComponent,
21+
},
1822
];
1923

2024
@NgModule({

src/app/app.module.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,47 @@
11
import { HttpClient, HttpClientModule } from '@angular/common/http';
22
import { NgModule } from '@angular/core';
3-
import { FormsModule } from '@angular/forms';
3+
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
4+
import { MatStepperModule } from '@angular/material/stepper';
45
import { BrowserModule } from '@angular/platform-browser';
56
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
6-
// NG Translate
77
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
88
import { TranslateHttpLoader } from '@ngx-translate/http-loader';
99
import 'reflect-metadata';
1010
import '../polyfills';
11+
import {MatFormFieldModule} from '@angular/material/form-field';
12+
import {MatInputModule} from '@angular/material/input';
1113
import { AppRoutingModule } from './app-routing.module';
1214
import { AppComponent } from './app.component';
15+
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
1316
import { CoreModule } from './core/core.module';
1417
import { DetailModule } from './detail/detail.module';
1518
import { HomeModule } from './home/home.module';
19+
import {MatIconModule} from '@angular/material/icon';
1620
import { SharedModule } from './shared/shared.module';
21+
import { WizardComponent } from './wizard/wizard.component';
1722

1823
// AoT requires an exported function for factories
1924
export function HttpLoaderFactory(http: HttpClient): TranslateHttpLoader {
2025
return new TranslateHttpLoader(http, './assets/i18n/', '.json');
2126
}
2227

2328
@NgModule({
24-
declarations: [AppComponent],
29+
declarations: [AppComponent, WizardComponent],
2530
imports: [
2631
AppRoutingModule,
32+
BrowserAnimationsModule,
2733
BrowserModule,
2834
CoreModule,
2935
DetailModule,
3036
FormsModule,
3137
HomeModule,
3238
HttpClientModule,
39+
MatFormFieldModule,
40+
MatIconModule,
41+
MatInputModule,
42+
MatStepperModule,
3343
NgbModule,
44+
ReactiveFormsModule,
3445
SharedModule,
3546
TranslateModule.forRoot({
3647
loader: {
@@ -43,4 +54,4 @@ export function HttpLoaderFactory(http: HttpClient): TranslateHttpLoader {
4354
providers: [],
4455
bootstrap: [AppComponent]
4556
})
46-
export class AppModule {}
57+
export class AppModule { }

0 commit comments

Comments
 (0)