1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21 package org.opencastproject.waveform.ffmpeg;
22
23 import org.opencastproject.job.api.AbstractJobProducer;
24 import org.opencastproject.job.api.Job;
25 import org.opencastproject.mediapackage.Attachment;
26 import org.opencastproject.mediapackage.MediaPackageElement.Type;
27 import org.opencastproject.mediapackage.MediaPackageElementBuilder;
28 import org.opencastproject.mediapackage.MediaPackageElementBuilderFactory;
29 import org.opencastproject.mediapackage.MediaPackageElementParser;
30 import org.opencastproject.mediapackage.MediaPackageException;
31 import org.opencastproject.mediapackage.Track;
32 import org.opencastproject.security.api.OrganizationDirectoryService;
33 import org.opencastproject.security.api.SecurityService;
34 import org.opencastproject.security.api.UserDirectoryService;
35 import org.opencastproject.serviceregistry.api.ServiceRegistry;
36 import org.opencastproject.serviceregistry.api.ServiceRegistryException;
37 import org.opencastproject.util.IoSupport;
38 import org.opencastproject.util.LoadUtil;
39 import org.opencastproject.util.NotFoundException;
40 import org.opencastproject.waveform.api.WaveformService;
41 import org.opencastproject.waveform.api.WaveformServiceException;
42 import org.opencastproject.workspace.api.Workspace;
43
44 import org.apache.commons.io.FileUtils;
45 import org.apache.commons.io.FilenameUtils;
46 import org.apache.commons.lang3.StringUtils;
47 import org.osgi.service.cm.ConfigurationException;
48 import org.osgi.service.cm.ManagedService;
49 import org.osgi.service.component.ComponentContext;
50 import org.osgi.service.component.annotations.Activate;
51 import org.osgi.service.component.annotations.Component;
52 import org.osgi.service.component.annotations.Reference;
53 import org.slf4j.Logger;
54 import org.slf4j.LoggerFactory;
55
56 import java.io.BufferedReader;
57 import java.io.File;
58 import java.io.FileInputStream;
59 import java.io.FileNotFoundException;
60 import java.io.IOException;
61 import java.io.InputStreamReader;
62 import java.net.URI;
63 import java.util.Arrays;
64 import java.util.Dictionary;
65 import java.util.List;
66 import java.util.concurrent.TimeUnit;
67
68
69
70
71
72
73 @Component(
74 immediate = true,
75 service = { WaveformService.class,ManagedService.class },
76 property = {
77 "service.description=Waveform Service"
78 }
79 )
80 public class WaveformServiceImpl extends AbstractJobProducer implements WaveformService, ManagedService {
81
82
83 protected static final Logger logger = LoggerFactory.getLogger(WaveformServiceImpl.class);
84
85
86 protected String binary = DEFAULT_FFMPEG_BINARY;
87
88
89 public static final String WAVEFORM_JOB_LOAD_CONFIG_KEY = "job.load.waveform";
90
91
92 public static final float DEFAULT_WAVEFORM_JOB_LOAD = 0.1f;
93
94
95 public static final String FFMPEG_BINARY_CONFIG_KEY = "org.opencastproject.composer.ffmpeg.path";
96
97
98 public static final String DEFAULT_FFMPEG_BINARY = "ffmpeg";
99
100
101 public static final String DEFAULT_WAVEFORM_SCALE = "lin";
102
103
104 public static final String WAVEFORM_SCALE_CONFIG_KEY = "waveform.scale";
105
106
107
108 public static final boolean DEFAULT_WAVEFORM_SPLIT_CHANNELS = false;
109
110
111 public static final String WAVEFORM_SPLIT_CHANNELS_CONFIG_KEY = "waveform.split.channels";
112
113
114 public static final String[] DEFAULT_WAVEFORM_COLOR = { "black" };
115
116
117 public static final String WAVEFORM_COLOR_CONFIG_KEY = "waveform.color";
118
119 public static final String DEFAULT_WAVEFORM_FILTER_MODE = "peak";
120
121 public static final String WAVEFORM_FILTER_MODE_CONFIG_KEY = "waveform.filter-mode";
122
123
124 public static final String DEFAULT_WAVEFORM_FILTER_PRE = null;
125
126
127 public static final String WAVEFORM_FILTER_PRE_CONFIG_KEY = "waveform.filter.pre";
128
129
130 public static final String DEFAULT_WAVEFORM_FILTER_POST = null;
131
132
133 public static final String WAVEFORM_FILTER_POST_CONFIG_KEY = "waveform.filter.post";
134
135
136 public static final String COLLECTION_ID = "waveform";
137
138
139 enum Operation {
140 Waveform
141 };
142
143
144 private float waveformJobLoad = DEFAULT_WAVEFORM_JOB_LOAD;
145
146
147 private String waveformScale = DEFAULT_WAVEFORM_SCALE;
148
149
150
151 private boolean waveformSplitChannels = DEFAULT_WAVEFORM_SPLIT_CHANNELS;
152
153
154 private String[] waveformColor = DEFAULT_WAVEFORM_COLOR;
155
156 private String waveformFilterMode = DEFAULT_WAVEFORM_FILTER_MODE;
157
158
159 private String waveformFilterPre = DEFAULT_WAVEFORM_FILTER_PRE;
160
161
162 private String waveformFilterPost = DEFAULT_WAVEFORM_FILTER_POST;
163
164
165 private ServiceRegistry serviceRegistry = null;
166
167
168 private Workspace workspace = null;
169
170
171 private SecurityService securityService = null;
172
173
174 private UserDirectoryService userDirectoryService = null;
175
176
177 private OrganizationDirectoryService organizationDirectoryService = null;
178
179 public WaveformServiceImpl() {
180 super(JOB_TYPE);
181 }
182
183 @Override
184 @Activate
185 public void activate(ComponentContext cc) {
186 super.activate(cc);
187 logger.info("Activate ffmpeg waveform service");
188 final String path = cc.getBundleContext().getProperty(FFMPEG_BINARY_CONFIG_KEY);
189 binary = (path == null ? DEFAULT_FFMPEG_BINARY : path);
190 logger.debug("ffmpeg binary set to {}", binary);
191 }
192
193 @Override
194 public void updated(Dictionary<String, ?> properties) throws ConfigurationException {
195 if (properties == null) {
196 return;
197 }
198 logger.debug("Configuring the waveform service");
199 waveformJobLoad = LoadUtil.getConfiguredLoadValue(properties,
200 WAVEFORM_JOB_LOAD_CONFIG_KEY, DEFAULT_WAVEFORM_JOB_LOAD, serviceRegistry);
201
202 Object val = properties.get(WAVEFORM_SCALE_CONFIG_KEY);
203 if (val != null) {
204 if (StringUtils.isNotEmpty((String) val)) {
205 if (!"lin".equals(val) && !"log".equals(val)) {
206 logger.warn("Waveform scale configuration value '{}' is not in set of predefined values (lin, log). "
207 + "The waveform image extraction job may fail.", val);
208 }
209 waveformScale = (String) val;
210 }
211 }
212
213 val = properties.get(WAVEFORM_SPLIT_CHANNELS_CONFIG_KEY);
214 if (val != null) {
215 waveformSplitChannels = Boolean.parseBoolean((String) val);
216 }
217
218 val = properties.get(WAVEFORM_COLOR_CONFIG_KEY);
219 if (val != null && StringUtils.isNotEmpty((String) val)) {
220 String colorValue = (String) val;
221 if (StringUtils.isNotEmpty(colorValue) && StringUtils.isNotBlank(colorValue)) {
222 waveformColor = StringUtils.split(colorValue, ", |:;");
223 }
224 }
225
226 val = properties.get(WAVEFORM_FILTER_MODE_CONFIG_KEY);
227 if (val != null && StringUtils.isNotEmpty((String) val)) {
228 if (!"average".equals(val) && !"peak".equals(val)) {
229 logger.warn("Waveform filter mode configuration value '{}' is not in set of predefined values (average, peak). "
230 + "The waveform image extraction job may fail.", val);
231 }
232 waveformFilterMode = (String) val;
233 }
234
235 val = properties.get(WAVEFORM_FILTER_PRE_CONFIG_KEY);
236 if (val != null) {
237 waveformFilterPre = StringUtils.trimToNull((String) val);
238 } else {
239 waveformFilterPre = null;
240 }
241
242 val = properties.get(WAVEFORM_FILTER_POST_CONFIG_KEY);
243 if (val != null) {
244 waveformFilterPost = StringUtils.trimToNull((String) val);
245 } else {
246 waveformFilterPost = null;
247 }
248 }
249
250
251
252
253
254
255
256 @Override
257 public Job createWaveformImage(
258 Track sourceTrack, int pixelsPerMinute, int minWidth, int maxWidth, int height, String color
259 ) throws MediaPackageException, WaveformServiceException {
260 try {
261 return serviceRegistry.createJob(jobType, Operation.Waveform.toString(),
262 Arrays.asList(
263 MediaPackageElementParser.getAsXml(sourceTrack),
264 Integer.toString(pixelsPerMinute),
265 Integer.toString(minWidth),
266 Integer.toString(maxWidth),
267 Integer.toString(height),
268 color
269 ),
270 waveformJobLoad
271 );
272 } catch (ServiceRegistryException ex) {
273 throw new WaveformServiceException("Unable to create waveform job", ex);
274 }
275 }
276
277
278
279
280
281
282 @Override
283 protected String process(Job job) throws Exception {
284 Operation op = null;
285 String operation = job.getOperation();
286 List<String> arguments = job.getArguments();
287 try {
288 op = Operation.valueOf(operation);
289 switch (op) {
290 case Waveform:
291 Track track = (Track) MediaPackageElementParser.getFromXml(arguments.get(0));
292 int pixelsPerMinute = Integer.parseInt(arguments.get(1));
293 int minWidth = Integer.parseInt(arguments.get(2));
294 int maxWidth = Integer.parseInt(arguments.get(3));
295 int height = Integer.parseInt(arguments.get(4));
296 String color = arguments.get(5);
297 Attachment waveformMpe = extractWaveform(track, pixelsPerMinute, minWidth, maxWidth, height, color);
298 return MediaPackageElementParser.getAsXml(waveformMpe);
299 default:
300 throw new ServiceRegistryException("This service can't handle operations of type '" + op + "'");
301 }
302 } catch (IndexOutOfBoundsException e) {
303 throw new ServiceRegistryException("This argument list for operation '" + op + "' does not meet expectations", e);
304 } catch (MediaPackageException | WaveformServiceException e) {
305 throw new ServiceRegistryException("Error handling operation '" + op + "'", e);
306 }
307 }
308
309
310
311
312
313
314
315
316
317
318
319
320
321 private Attachment extractWaveform(
322 Track track, int pixelsPerMinute, int minWidth, int maxWidth, int height, String color
323 ) throws WaveformServiceException {
324 if (!track.hasAudio()) {
325 throw new WaveformServiceException("Track has no audio");
326 }
327
328
329 File mediaFile;
330 try {
331 mediaFile = workspace.get(track.getURI());
332 } catch (NotFoundException e) {
333 throw new WaveformServiceException(
334 "Error finding the media file in the workspace", e);
335 } catch (IOException e) {
336 throw new WaveformServiceException(
337 "Error reading the media file in the workspace", e);
338 }
339
340 String waveformFilePath = FilenameUtils.removeExtension(mediaFile.getAbsolutePath())
341 .concat('-' + track.getIdentifier()).concat("-waveform.png");
342
343 int width = getWaveformImageWidth(track, pixelsPerMinute, minWidth, maxWidth);
344
345
346 String[] command = new String[] {
347 binary,
348 "-nostats", "-nostdin", "-hide_banner",
349 "-i", mediaFile.getAbsolutePath(),
350 "-lavfi", createWaveformFilter(width, height, color),
351 "-frames:v", "1",
352 "-an", "-vn", "-sn",
353 waveformFilePath
354 };
355 logger.debug("Start waveform ffmpeg process: {}", StringUtils.join(command, " "));
356 logger.info("Create waveform image file for track '{}' at {}", track.getIdentifier(), waveformFilePath);
357
358
359 ProcessBuilder pb = new ProcessBuilder(command);
360 pb.redirectErrorStream(true);
361 Process ffmpegProcess = null;
362 int exitCode = 1;
363 BufferedReader errStream = null;
364 try {
365 ffmpegProcess = pb.start();
366
367 errStream = new BufferedReader(new InputStreamReader(ffmpegProcess.getInputStream()));
368 String line = errStream.readLine();
369 while (line != null) {
370 logger.debug(line);
371 line = errStream.readLine();
372 }
373
374 exitCode = ffmpegProcess.waitFor();
375 } catch (IOException ex) {
376 throw new WaveformServiceException("Start ffmpeg process failed", ex);
377 } catch (InterruptedException ex) {
378 throw new WaveformServiceException("Waiting for encoder process exited was interrupted unexpectedly", ex);
379 } finally {
380 IoSupport.closeQuietly(ffmpegProcess);
381 IoSupport.closeQuietly(errStream);
382 if (exitCode != 0) {
383 try {
384 FileUtils.forceDelete(new File(waveformFilePath));
385 } catch (IOException e) {
386
387 }
388 }
389 }
390
391 if (exitCode != 0) {
392 throw new WaveformServiceException(String.format("The encoder process exited abnormally with exit code %s "
393 + "using command\n%s", exitCode, String.join(" ", command)));
394 }
395
396
397 FileInputStream waveformFileInputStream = null;
398 URI waveformFileUri;
399 try {
400 waveformFileInputStream = new FileInputStream(waveformFilePath);
401 waveformFileUri = workspace.putInCollection(COLLECTION_ID,
402 FilenameUtils.getName(waveformFilePath), waveformFileInputStream);
403 logger.info("Copied the created waveform to the workspace {}", waveformFileUri);
404 } catch (FileNotFoundException ex) {
405 throw new WaveformServiceException(String.format("Waveform image file '%s' not found", waveformFilePath), ex);
406 } catch (IOException ex) {
407 throw new WaveformServiceException(String.format(
408 "Can't write waveform image file '%s' to workspace", waveformFilePath), ex);
409 } catch (IllegalArgumentException ex) {
410 throw new WaveformServiceException(ex);
411 } finally {
412 IoSupport.closeQuietly(waveformFileInputStream);
413 logger.info("Deleted local waveform image file at {}", waveformFilePath);
414 FileUtils.deleteQuietly(new File(waveformFilePath));
415 }
416
417
418 MediaPackageElementBuilder mpElementBuilder = MediaPackageElementBuilderFactory.newInstance().newElementBuilder();
419
420 Attachment waveformMpe = (Attachment) mpElementBuilder.elementFromURI(
421 waveformFileUri, Type.Attachment, track.getFlavor());
422 waveformMpe.generateIdentifier();
423 return waveformMpe;
424 }
425
426
427
428
429
430
431
432
433
434 private String createWaveformFilter(int width, int height, String color) {
435 StringBuilder filterBuilder = new StringBuilder();
436 if (waveformFilterPre != null) {
437 filterBuilder.append(waveformFilterPre);
438 filterBuilder.append(",");
439 }
440 String[] waveformOperationColors = null;
441
442 if (StringUtils.isNotBlank(color)) {
443 waveformOperationColors = StringUtils.split(color, "|");
444 } else {
445 waveformOperationColors = waveformColor;
446 }
447 filterBuilder.append("showwavespic=");
448 filterBuilder.append("split_channels=");
449 filterBuilder.append(waveformSplitChannels ? 1 : 0);
450 filterBuilder.append(":s=");
451 filterBuilder.append(width);
452 filterBuilder.append("x");
453 filterBuilder.append(height);
454 filterBuilder.append(":scale=");
455 filterBuilder.append(waveformScale);
456 filterBuilder.append(":filter=");
457 filterBuilder.append(waveformFilterMode);
458 filterBuilder.append(":colors=");
459 filterBuilder.append(StringUtils.join(Arrays.asList(waveformOperationColors), "|"));
460 if (waveformFilterPost != null) {
461 filterBuilder.append(",");
462 filterBuilder.append(waveformFilterPost);
463 }
464 return filterBuilder.toString();
465 }
466
467
468
469
470
471
472
473
474
475
476 private int getWaveformImageWidth(Track track, int pixelsPerMinute, int minWidth, int maxWidth) {
477 int imageWidth = minWidth;
478 if (track.getDuration() > 0) {
479 int trackDurationMinutes = (int) TimeUnit.MILLISECONDS.toMinutes(track.getDuration());
480 if (pixelsPerMinute > 0 && trackDurationMinutes > 0) {
481 imageWidth = Math.max(minWidth, trackDurationMinutes * pixelsPerMinute);
482 imageWidth = Math.min(maxWidth, imageWidth);
483 }
484 }
485 return imageWidth;
486 }
487
488 @Override
489 protected ServiceRegistry getServiceRegistry() {
490 return serviceRegistry;
491 }
492
493 @Override
494 protected SecurityService getSecurityService() {
495 return securityService;
496 }
497
498 @Override
499 protected UserDirectoryService getUserDirectoryService() {
500 return userDirectoryService;
501 }
502
503 @Override
504 protected OrganizationDirectoryService getOrganizationDirectoryService() {
505 return organizationDirectoryService;
506 }
507
508 @Reference
509 public void setServiceRegistry(ServiceRegistry serviceRegistry) {
510 this.serviceRegistry = serviceRegistry;
511 }
512
513 @Reference
514 public void setSecurityService(SecurityService securityService) {
515 this.securityService = securityService;
516 }
517
518 @Reference
519 public void setUserDirectoryService(UserDirectoryService userDirectoryService) {
520 this.userDirectoryService = userDirectoryService;
521 }
522
523 @Reference
524 public void setOrganizationDirectoryService(OrganizationDirectoryService organizationDirectoryService) {
525 this.organizationDirectoryService = organizationDirectoryService;
526 }
527
528 @Reference
529 public void setWorkspace(Workspace workspace) {
530 this.workspace = workspace;
531 }
532 }