diff options
author | Chocobozzz <me@florianbigard.com> | 2022-02-11 10:51:33 +0100 |
---|---|---|
committer | Chocobozzz <chocobozzz@cpy.re> | 2022-02-28 10:42:19 +0100 |
commit | c729caf6cc34630877a0e5a1bda1719384cd0c8a (patch) | |
tree | 1d2e13722e518c73d2c9e6f0969615e29d51cf8c /client/src/app/+video-editor | |
parent | a24bf4dc659cebb65d887862bf21d7a35e9ec791 (diff) | |
download | PeerTube-c729caf6cc34630877a0e5a1bda1719384cd0c8a.tar.gz PeerTube-c729caf6cc34630877a0e5a1bda1719384cd0c8a.tar.zst PeerTube-c729caf6cc34630877a0e5a1bda1719384cd0c8a.zip |
Add basic video editor support
Diffstat (limited to 'client/src/app/+video-editor')
10 files changed, 473 insertions, 0 deletions
diff --git a/client/src/app/+video-editor/edit/index.ts b/client/src/app/+video-editor/edit/index.ts new file mode 100644 index 000000000..390ca80fc --- /dev/null +++ b/client/src/app/+video-editor/edit/index.ts | |||
@@ -0,0 +1,2 @@ | |||
1 | export * from './video-editor-edit.component' | ||
2 | export * from './video-editor-edit.resolver' | ||
diff --git a/client/src/app/+video-editor/edit/video-editor-edit.component.html b/client/src/app/+video-editor/edit/video-editor-edit.component.html new file mode 100644 index 000000000..d33dfaf18 --- /dev/null +++ b/client/src/app/+video-editor/edit/video-editor-edit.component.html | |||
@@ -0,0 +1,88 @@ | |||
1 | <div class="margin-content"> | ||
2 | <h1 class="title-page title-page-single" i18n>Edit {{ video.name }}</h1> | ||
3 | |||
4 | <div class="columns"> | ||
5 | <form role="form" [formGroup]="form"> | ||
6 | |||
7 | <div class="section cut" formGroupName="cut"> | ||
8 | <h2 i18n>CUT VIDEO</h2> | ||
9 | |||
10 | <div i18n class="description">Set a new start/end.</div> | ||
11 | |||
12 | <div class="form-group"> | ||
13 | <label i18n for="cutStart">New start</label> | ||
14 | <my-timestamp-input inputName="cutStart" [disableBorder]="false" [maxTimestamp]="video.duration" formControlName="start"></my-timestamp-input> | ||
15 | </div> | ||
16 | |||
17 | <div class="form-group"> | ||
18 | <label i18n for="cutEnd">New end</label> | ||
19 | <my-timestamp-input inputName="cutEnd" [disableBorder]="false" [maxTimestamp]="video.duration" formControlName="end"></my-timestamp-input> | ||
20 | </div> | ||
21 | </div> | ||
22 | |||
23 | <div class="section" formGroupName="add-intro"> | ||
24 | <h2 i18n>ADD INTRO</h2> | ||
25 | |||
26 | <div i18n class="description">Concatenate a file at the beginning of the video.</div> | ||
27 | |||
28 | <div class="form-group"> | ||
29 | <my-reactive-file | ||
30 | formControlName="file" inputName="addIntroFile" i18n-inputLabel inputLabel="Select the intro video file" | ||
31 | [extensions]="videoExtensions" [displayFilename]="true" | ||
32 | [ngbTooltip]="getIntroOutroTooltip()" | ||
33 | ></my-reactive-file> | ||
34 | </div> | ||
35 | </div> | ||
36 | |||
37 | <div class="section" formGroupName="add-outro"> | ||
38 | <h2 i18n>ADD OUTRO</h2> | ||
39 | |||
40 | <div i18n class="description">Concatenate a file at the end of the video.</div> | ||
41 | |||
42 | <div class="form-group"> | ||
43 | <my-reactive-file | ||
44 | formControlName="file" inputName="addOutroFile" i18n-inputLabel inputLabel="Select the outro video file" | ||
45 | [extensions]="videoExtensions" [displayFilename]="true" | ||
46 | [ngbTooltip]="getIntroOutroTooltip()" | ||
47 | ></my-reactive-file> | ||
48 | </div> | ||
49 | </div> | ||
50 | |||
51 | <div class="section" formGroupName="add-watermark"> | ||
52 | <h2 i18n>ADD WATERMARK</h2> | ||
53 | |||
54 | <div i18n class="description">Add a watermark image to the video.</div> | ||
55 | |||
56 | <div class="form-group"> | ||
57 | <my-reactive-file | ||
58 | formControlName="file" inputName="addWatermarkFile" i18n-inputLabel inputLabel="Select watermark image file" | ||
59 | [extensions]="imageExtensions" [displayFilename]="true" | ||
60 | [ngbTooltip]="getWatermarkTooltip()" | ||
61 | ></my-reactive-file> | ||
62 | </div> | ||
63 | </div> | ||
64 | |||
65 | <my-button | ||
66 | className="orange-button" i18n-label label="Run video edition" icon="circle-tick" | ||
67 | (click)="runEdition()" (keydown.enter)="runEdition()" | ||
68 | [disabled]="!form.valid || isRunningEdition || noEdition()" | ||
69 | ></my-button> | ||
70 | </form> | ||
71 | |||
72 | |||
73 | <div class="information"> | ||
74 | <div> | ||
75 | <label i18n>Video before edition</label> | ||
76 | <my-embed [video]="video"></my-embed> | ||
77 | </div> | ||
78 | |||
79 | <div *ngIf="!noEdition()"> | ||
80 | <label i18n>Edition tasks:</label> | ||
81 | |||
82 | <ol> | ||
83 | <li *ngFor="let task of getTasksSummary()">{{ task }}</li> | ||
84 | </ol> | ||
85 | </div> | ||
86 | </div> | ||
87 | </div> | ||
88 | </div> | ||
diff --git a/client/src/app/+video-editor/edit/video-editor-edit.component.scss b/client/src/app/+video-editor/edit/video-editor-edit.component.scss new file mode 100644 index 000000000..43f336f59 --- /dev/null +++ b/client/src/app/+video-editor/edit/video-editor-edit.component.scss | |||
@@ -0,0 +1,76 @@ | |||
1 | @use '_variables' as *; | ||
2 | @use '_mixins' as *; | ||
3 | |||
4 | .columns { | ||
5 | display: flex; | ||
6 | |||
7 | .information { | ||
8 | width: 100%; | ||
9 | margin-left: 50px; | ||
10 | |||
11 | > div { | ||
12 | margin-bottom: 30px; | ||
13 | } | ||
14 | |||
15 | @media screen and (max-width: $small-view) { | ||
16 | display: none; | ||
17 | } | ||
18 | } | ||
19 | } | ||
20 | |||
21 | h1 { | ||
22 | font-size: 20px; | ||
23 | } | ||
24 | |||
25 | h2 { | ||
26 | font-weight: $font-bold; | ||
27 | font-size: 16px; | ||
28 | color: pvar(--mainColor); | ||
29 | background-color: pvar(--mainBackgroundColor); | ||
30 | padding: 0 5px; | ||
31 | width: fit-content; | ||
32 | margin: -8px 0 0; | ||
33 | } | ||
34 | |||
35 | .section { | ||
36 | $min-width: 600px; | ||
37 | |||
38 | @include padding-left(10px); | ||
39 | |||
40 | min-width: $min-width; | ||
41 | |||
42 | margin-bottom: 50px; | ||
43 | border: 1px solid $separator-border-color; | ||
44 | border-radius: 5px; | ||
45 | width: fit-content; | ||
46 | |||
47 | .form-group, | ||
48 | .description { | ||
49 | @include margin-left(5px); | ||
50 | } | ||
51 | |||
52 | .description { | ||
53 | color: pvar(--greyForegroundColor); | ||
54 | margin-top: 5px; | ||
55 | margin-bottom: 15px; | ||
56 | } | ||
57 | |||
58 | @media screen and (max-width: $min-width) { | ||
59 | min-width: none; | ||
60 | } | ||
61 | } | ||
62 | |||
63 | my-timestamp-input { | ||
64 | display: block; | ||
65 | } | ||
66 | |||
67 | my-embed { | ||
68 | display: block; | ||
69 | max-width: 500px; | ||
70 | width: 100%; | ||
71 | } | ||
72 | |||
73 | my-reactive-file { | ||
74 | display: block; | ||
75 | width: fit-content; | ||
76 | } | ||
diff --git a/client/src/app/+video-editor/edit/video-editor-edit.component.ts b/client/src/app/+video-editor/edit/video-editor-edit.component.ts new file mode 100644 index 000000000..93d7ffcec --- /dev/null +++ b/client/src/app/+video-editor/edit/video-editor-edit.component.ts | |||
@@ -0,0 +1,202 @@ | |||
1 | import { Component, OnInit } from '@angular/core' | ||
2 | import { ActivatedRoute, Router } from '@angular/router' | ||
3 | import { ConfirmService, Notifier, ServerService } from '@app/core' | ||
4 | import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' | ||
5 | import { Video, VideoDetails } from '@app/shared/shared-main' | ||
6 | import { LoadingBarService } from '@ngx-loading-bar/core' | ||
7 | import { secondsToTime } from '@shared/core-utils' | ||
8 | import { VideoEditorTask, VideoEditorTaskCut } from '@shared/models' | ||
9 | import { VideoEditorService } from '../shared' | ||
10 | |||
11 | @Component({ | ||
12 | selector: 'my-video-editor-edit', | ||
13 | templateUrl: './video-editor-edit.component.html', | ||
14 | styleUrls: [ './video-editor-edit.component.scss' ] | ||
15 | }) | ||
16 | export class VideoEditorEditComponent extends FormReactive implements OnInit { | ||
17 | isRunningEdition = false | ||
18 | |||
19 | video: VideoDetails | ||
20 | |||
21 | constructor ( | ||
22 | protected formValidatorService: FormValidatorService, | ||
23 | private serverService: ServerService, | ||
24 | private notifier: Notifier, | ||
25 | private router: Router, | ||
26 | private route: ActivatedRoute, | ||
27 | private videoEditorService: VideoEditorService, | ||
28 | private loadingBar: LoadingBarService, | ||
29 | private confirmService: ConfirmService | ||
30 | ) { | ||
31 | super() | ||
32 | } | ||
33 | |||
34 | ngOnInit () { | ||
35 | this.video = this.route.snapshot.data.video | ||
36 | |||
37 | const defaultValues = { | ||
38 | cut: { | ||
39 | start: 0, | ||
40 | end: this.video.duration | ||
41 | } | ||
42 | } | ||
43 | |||
44 | this.buildForm({ | ||
45 | cut: { | ||
46 | start: null, | ||
47 | end: null | ||
48 | }, | ||
49 | 'add-intro': { | ||
50 | file: null | ||
51 | }, | ||
52 | 'add-outro': { | ||
53 | file: null | ||
54 | }, | ||
55 | 'add-watermark': { | ||
56 | file: null | ||
57 | } | ||
58 | }, defaultValues) | ||
59 | } | ||
60 | |||
61 | get videoExtensions () { | ||
62 | return this.serverService.getHTMLConfig().video.file.extensions | ||
63 | } | ||
64 | |||
65 | get imageExtensions () { | ||
66 | return this.serverService.getHTMLConfig().video.image.extensions | ||
67 | } | ||
68 | |||
69 | async runEdition () { | ||
70 | if (this.isRunningEdition) return | ||
71 | |||
72 | const title = $localize`Are you sure you want to edit "${this.video.name}"?` | ||
73 | const listHTML = this.getTasksSummary().map(t => `<li>${t}</li>`).join('') | ||
74 | |||
75 | // eslint-disable-next-line max-len | ||
76 | const confirmHTML = $localize`The current video will be overwritten by this edited video and <strong>you won't be able to recover it</strong>.<br /><br />` + | ||
77 | $localize`As a reminder, the following tasks will be executed: <ol>${listHTML}</ol>` | ||
78 | |||
79 | if (await this.confirmService.confirm(confirmHTML, title) !== true) return | ||
80 | |||
81 | this.isRunningEdition = true | ||
82 | |||
83 | const tasks = this.buildTasks() | ||
84 | |||
85 | this.loadingBar.useRef().start() | ||
86 | |||
87 | return this.videoEditorService.editVideo(this.video.uuid, tasks) | ||
88 | .subscribe({ | ||
89 | next: () => { | ||
90 | this.notifier.success($localize`Video updated.`) | ||
91 | this.router.navigateByUrl(Video.buildWatchUrl(this.video)) | ||
92 | }, | ||
93 | |||
94 | error: err => { | ||
95 | this.loadingBar.useRef().complete() | ||
96 | this.isRunningEdition = false | ||
97 | this.notifier.error(err.message) | ||
98 | console.error(err) | ||
99 | } | ||
100 | }) | ||
101 | } | ||
102 | |||
103 | getIntroOutroTooltip () { | ||
104 | return $localize`(extensions: ${this.videoExtensions.join(', ')})` | ||
105 | } | ||
106 | |||
107 | getWatermarkTooltip () { | ||
108 | return $localize`(extensions: ${this.imageExtensions.join(', ')})` | ||
109 | } | ||
110 | |||
111 | noEdition () { | ||
112 | return this.buildTasks().length === 0 | ||
113 | } | ||
114 | |||
115 | getTasksSummary () { | ||
116 | const tasks = this.buildTasks() | ||
117 | |||
118 | return tasks.map(t => { | ||
119 | if (t.name === 'add-intro') { | ||
120 | return $localize`"${this.getFilename(t.options.file)}" will be added at the beggining of the video` | ||
121 | } | ||
122 | |||
123 | if (t.name === 'add-outro') { | ||
124 | return $localize`"${this.getFilename(t.options.file)}" will be added at the end of the video` | ||
125 | } | ||
126 | |||
127 | if (t.name === 'add-watermark') { | ||
128 | return $localize`"${this.getFilename(t.options.file)}" image watermark will be added to the video` | ||
129 | } | ||
130 | |||
131 | if (t.name === 'cut') { | ||
132 | const { start, end } = t.options | ||
133 | |||
134 | if (start !== undefined && end !== undefined) { | ||
135 | return $localize`Video will begin at ${secondsToTime(start)} and stop at ${secondsToTime(end)}` | ||
136 | } | ||
137 | |||
138 | if (start !== undefined) { | ||
139 | return $localize`Video will begin at ${secondsToTime(start)}` | ||
140 | } | ||
141 | |||
142 | if (end !== undefined) { | ||
143 | return $localize`Video will stop at ${secondsToTime(end)}` | ||
144 | } | ||
145 | } | ||
146 | |||
147 | return '' | ||
148 | }) | ||
149 | } | ||
150 | |||
151 | private getFilename (obj: any) { | ||
152 | return obj.name | ||
153 | } | ||
154 | |||
155 | private buildTasks () { | ||
156 | const tasks: VideoEditorTask[] = [] | ||
157 | const value = this.form.value | ||
158 | |||
159 | const cut = value['cut'] | ||
160 | if (cut['start'] !== 0 || cut['end'] !== this.video.duration) { | ||
161 | |||
162 | const options: VideoEditorTaskCut['options'] = {} | ||
163 | if (cut['start'] !== 0) options.start = cut['start'] | ||
164 | if (cut['end'] !== this.video.duration) options.end = cut['end'] | ||
165 | |||
166 | tasks.push({ | ||
167 | name: 'cut', | ||
168 | options | ||
169 | }) | ||
170 | } | ||
171 | |||
172 | if (value['add-intro']?.['file']) { | ||
173 | tasks.push({ | ||
174 | name: 'add-intro', | ||
175 | options: { | ||
176 | file: value['add-intro']['file'] | ||
177 | } | ||
178 | }) | ||
179 | } | ||
180 | |||
181 | if (value['add-outro']?.['file']) { | ||
182 | tasks.push({ | ||
183 | name: 'add-outro', | ||
184 | options: { | ||
185 | file: value['add-outro']['file'] | ||
186 | } | ||
187 | }) | ||
188 | } | ||
189 | |||
190 | if (value['add-watermark']?.['file']) { | ||
191 | tasks.push({ | ||
192 | name: 'add-watermark', | ||
193 | options: { | ||
194 | file: value['add-watermark']['file'] | ||
195 | } | ||
196 | }) | ||
197 | } | ||
198 | |||
199 | return tasks | ||
200 | } | ||
201 | |||
202 | } | ||
diff --git a/client/src/app/+video-editor/edit/video-editor-edit.resolver.ts b/client/src/app/+video-editor/edit/video-editor-edit.resolver.ts new file mode 100644 index 000000000..7b95ae834 --- /dev/null +++ b/client/src/app/+video-editor/edit/video-editor-edit.resolver.ts | |||
@@ -0,0 +1,18 @@ | |||
1 | |||
2 | import { Injectable } from '@angular/core' | ||
3 | import { ActivatedRouteSnapshot, Resolve } from '@angular/router' | ||
4 | import { VideoService } from '@app/shared/shared-main' | ||
5 | |||
6 | @Injectable() | ||
7 | export class VideoEditorEditResolver implements Resolve<any> { | ||
8 | constructor ( | ||
9 | private videoService: VideoService | ||
10 | ) { | ||
11 | } | ||
12 | |||
13 | resolve (route: ActivatedRouteSnapshot) { | ||
14 | const videoId: string = route.params['videoId'] | ||
15 | |||
16 | return this.videoService.getVideo({ videoId }) | ||
17 | } | ||
18 | } | ||
diff --git a/client/src/app/+video-editor/index.ts b/client/src/app/+video-editor/index.ts new file mode 100644 index 000000000..5a9e9fdd0 --- /dev/null +++ b/client/src/app/+video-editor/index.ts | |||
@@ -0,0 +1 @@ | |||
export * from './video-editor.module' | |||
diff --git a/client/src/app/+video-editor/shared/index.ts b/client/src/app/+video-editor/shared/index.ts new file mode 100644 index 000000000..eaf88b6f4 --- /dev/null +++ b/client/src/app/+video-editor/shared/index.ts | |||
@@ -0,0 +1 @@ | |||
export * from './video-editor.service' | |||
diff --git a/client/src/app/+video-editor/shared/video-editor.service.ts b/client/src/app/+video-editor/shared/video-editor.service.ts new file mode 100644 index 000000000..5b7053039 --- /dev/null +++ b/client/src/app/+video-editor/shared/video-editor.service.ts | |||
@@ -0,0 +1,28 @@ | |||
1 | import { catchError } from 'rxjs' | ||
2 | import { HttpClient } from '@angular/common/http' | ||
3 | import { Injectable } from '@angular/core' | ||
4 | import { RestExtractor } from '@app/core' | ||
5 | import { objectToFormData } from '@app/helpers' | ||
6 | import { VideoService } from '@app/shared/shared-main' | ||
7 | import { VideoEditorCreateEdition, VideoEditorTask } from '@shared/models' | ||
8 | |||
9 | @Injectable() | ||
10 | export class VideoEditorService { | ||
11 | |||
12 | constructor ( | ||
13 | private authHttp: HttpClient, | ||
14 | private restExtractor: RestExtractor | ||
15 | ) {} | ||
16 | |||
17 | editVideo (videoId: number | string, tasks: VideoEditorTask[]) { | ||
18 | const url = VideoService.BASE_VIDEO_URL + '/' + videoId + '/editor/edit' | ||
19 | const body: VideoEditorCreateEdition = { | ||
20 | tasks | ||
21 | } | ||
22 | |||
23 | const data = objectToFormData(body) | ||
24 | |||
25 | return this.authHttp.post(url, data) | ||
26 | .pipe(catchError(err => this.restExtractor.handleError(err))) | ||
27 | } | ||
28 | } | ||
diff --git a/client/src/app/+video-editor/video-editor-routing.module.ts b/client/src/app/+video-editor/video-editor-routing.module.ts new file mode 100644 index 000000000..9f37a0dae --- /dev/null +++ b/client/src/app/+video-editor/video-editor-routing.module.ts | |||
@@ -0,0 +1,30 @@ | |||
1 | import { NgModule } from '@angular/core' | ||
2 | import { RouterModule, Routes } from '@angular/router' | ||
3 | import { VideoEditorEditResolver } from './edit' | ||
4 | import { VideoEditorEditComponent } from './edit/video-editor-edit.component' | ||
5 | |||
6 | const videoEditorRoutes: Routes = [ | ||
7 | { | ||
8 | path: '', | ||
9 | children: [ | ||
10 | { | ||
11 | path: 'edit/:videoId', | ||
12 | component: VideoEditorEditComponent, | ||
13 | data: { | ||
14 | meta: { | ||
15 | title: $localize`Edit video` | ||
16 | } | ||
17 | }, | ||
18 | resolve: { | ||
19 | video: VideoEditorEditResolver | ||
20 | } | ||
21 | } | ||
22 | ] | ||
23 | } | ||
24 | ] | ||
25 | |||
26 | @NgModule({ | ||
27 | imports: [ RouterModule.forChild(videoEditorRoutes) ], | ||
28 | exports: [ RouterModule ] | ||
29 | }) | ||
30 | export class VideoEditorRoutingModule {} | ||
diff --git a/client/src/app/+video-editor/video-editor.module.ts b/client/src/app/+video-editor/video-editor.module.ts new file mode 100644 index 000000000..7bbebc17b --- /dev/null +++ b/client/src/app/+video-editor/video-editor.module.ts | |||
@@ -0,0 +1,27 @@ | |||
1 | import { NgModule } from '@angular/core' | ||
2 | import { SharedFormModule } from '@app/shared/shared-forms' | ||
3 | import { SharedMainModule } from '@app/shared/shared-main' | ||
4 | import { VideoEditorEditComponent, VideoEditorEditResolver } from './edit' | ||
5 | import { VideoEditorService } from './shared' | ||
6 | import { VideoEditorRoutingModule } from './video-editor-routing.module' | ||
7 | |||
8 | @NgModule({ | ||
9 | imports: [ | ||
10 | VideoEditorRoutingModule, | ||
11 | |||
12 | SharedMainModule, | ||
13 | SharedFormModule | ||
14 | ], | ||
15 | |||
16 | declarations: [ | ||
17 | VideoEditorEditComponent | ||
18 | ], | ||
19 | |||
20 | exports: [], | ||
21 | |||
22 | providers: [ | ||
23 | VideoEditorService, | ||
24 | VideoEditorEditResolver | ||
25 | ] | ||
26 | }) | ||
27 | export class VideoEditorModule { } | ||