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