1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 package org.opencastproject.workflow.handler.composer;
23
24 import static java.lang.String.format;
25 import static org.opencastproject.util.EqualsUtil.eq;
26
27 import org.opencastproject.composer.api.ComposerService;
28 import org.opencastproject.composer.api.EncodingProfile;
29 import org.opencastproject.job.api.Job;
30 import org.opencastproject.job.api.JobBarrier;
31 import org.opencastproject.job.api.JobContext;
32 import org.opencastproject.mediapackage.Attachment;
33 import org.opencastproject.mediapackage.MediaPackage;
34 import org.opencastproject.mediapackage.MediaPackageElementFlavor;
35 import org.opencastproject.mediapackage.MediaPackageElementParser;
36 import org.opencastproject.mediapackage.MediaPackageException;
37 import org.opencastproject.mediapackage.MediaPackageSupport;
38 import org.opencastproject.mediapackage.Track;
39 import org.opencastproject.mediapackage.VideoStream;
40 import org.opencastproject.mediapackage.selector.TrackSelector;
41 import org.opencastproject.serviceregistry.api.ServiceRegistry;
42 import org.opencastproject.util.JobUtil;
43 import org.opencastproject.util.MimeTypes;
44 import org.opencastproject.util.UnknownFileTypeException;
45 import org.opencastproject.util.data.Collections;
46 import org.opencastproject.workflow.api.AbstractWorkflowOperationHandler;
47 import org.opencastproject.workflow.api.ConfiguredTagsAndFlavors;
48 import org.opencastproject.workflow.api.WorkflowInstance;
49 import org.opencastproject.workflow.api.WorkflowOperationException;
50 import org.opencastproject.workflow.api.WorkflowOperationHandler;
51 import org.opencastproject.workflow.api.WorkflowOperationInstance;
52 import org.opencastproject.workflow.api.WorkflowOperationResult;
53 import org.opencastproject.workflow.api.WorkflowOperationResult.Action;
54 import org.opencastproject.workspace.api.Workspace;
55
56 import org.apache.commons.io.FilenameUtils;
57 import org.osgi.service.component.annotations.Component;
58 import org.osgi.service.component.annotations.Reference;
59 import org.slf4j.Logger;
60 import org.slf4j.LoggerFactory;
61
62 import java.net.URI;
63 import java.util.ArrayList;
64 import java.util.Arrays;
65 import java.util.IllegalFormatException;
66 import java.util.List;
67 import java.util.Locale;
68 import java.util.Objects;
69 import java.util.Optional;
70 import java.util.stream.Collectors;
71
72
73
74
75 @Component(
76 immediate = true,
77 service = WorkflowOperationHandler.class,
78 property = {
79 "service.description=Image Workflow Operation Handler",
80 "workflow.operation=image"
81 }
82 )
83 public class ImageWorkflowOperationHandler extends AbstractWorkflowOperationHandler {
84
85 private static final Logger logger = LoggerFactory.getLogger(ImageWorkflowOperationHandler.class);
86
87
88 public static final String OPT_PROFILES = "encoding-profile";
89 public static final String OPT_POSITIONS = "time";
90 public static final String OPT_TARGET_BASE_NAME_FORMAT_SECOND = "target-base-name-format-second";
91 public static final String OPT_TARGET_BASE_NAME_FORMAT_PERCENT = "target-base-name-format-percent";
92 public static final String OPT_END_MARGIN = "end-margin";
93
94 private static final long END_MARGIN_DEFAULT = 100;
95 public static final double SINGLE_FRAME_POS = 0.0;
96
97
98 private ComposerService composerService = null;
99
100
101 private Workspace workspace = null;
102
103
104
105
106
107
108
109 @Reference
110 protected void setComposerService(ComposerService composerService) {
111 this.composerService = composerService;
112 }
113
114
115
116
117
118
119
120
121 @Reference
122 public void setWorkspace(Workspace workspace) {
123 this.workspace = workspace;
124 }
125
126 @Override
127 public WorkflowOperationResult start(final WorkflowInstance wi, JobContext ctx)
128 throws WorkflowOperationException {
129 logger.debug("Running image workflow operation on {}", wi);
130 try {
131 MediaPackage mp = wi.getMediaPackage();
132 final Cfg cfg = configure(mp, wi);
133 mp = MediaPackageSupport.copy(mp);
134
135
136 if (cfg.sourceTracks.size() == 0) {
137 logger.info("No source tracks found in media package {}, skipping operation", mp.getIdentifier());
138 return this.createResult(mp, Action.SKIP);
139 }
140
141 final List<Extraction> extractions = cfg.sourceTracks.stream().flatMap(track -> {
142 final List<MediaPosition> positions = limit(track, cfg.positions);
143 if (positions.size() != cfg.positions.size()) {
144 logger.warn("Could not apply all configured positions to track {}", track);
145 }
146 logger.info("Extracting images from {} at position {}", track, positions);
147
148 return cfg.profiles.stream()
149 .map(profile -> {
150 try {
151 return new Extraction(extractImages(this, track, profile, positions, cfg), track, profile, positions);
152 } catch (WorkflowOperationException e) {
153 throw new RuntimeException(e);
154 }
155 });
156 }).collect(Collectors.toList());
157 final List<Job> extractionJobs = concatJobs(extractions);
158 final JobBarrier.Result extractionResult = JobUtil.waitForJobs(this.serviceRegistry, extractionJobs);
159 if (extractionResult.isSuccess()) {
160
161 for (final Extraction extraction : extractions) {
162 final List<Attachment> images = getImages(extraction.job);
163 final int expectedNrOfImages = extraction.positions.size();
164 if (images.size() == expectedNrOfImages) {
165
166 int size = Math.min(images.size(), extraction.positions.size());
167 for (int i = 0; i < size; i++) {
168 Attachment image = images.get(i);
169 MediaPosition position = extraction.positions.get(i);
170 adjustMetadata(extraction, image, cfg);
171 if (image.getIdentifier() == null) {
172 image.generateIdentifier();
173 }
174 mp.addDerived(image, extraction.track);
175 String fileName = createFileName(
176 extraction.profile.getSuffix(), extraction.track.getURI(), position, cfg);
177 moveToWorkspace(this, mp, image, fileName);
178 }
179 } else {
180
181 throw new WorkflowOperationException(
182 format("Only %s of %s images have been extracted from track %s",
183 images.size(), expectedNrOfImages, extraction.track));
184 }
185 }
186 return this.createResult(mp, Action.CONTINUE, JobUtil.sumQueueTime(extractionJobs));
187 } else {
188 throw new WorkflowOperationException("Image extraction failed");
189 }
190 } catch (Exception e) {
191 throw new WorkflowOperationException(e);
192 }
193 }
194
195
196
197
198
199 protected void adjustMetadata(Extraction extraction, Attachment image, Cfg cfg) {
200
201 for (final MediaPackageElementFlavor flavor : cfg.targetImageFlavor) {
202 final String flavorType = eq("*", flavor.getType())
203 ? extraction.track.getFlavor().getType()
204 : flavor.getType();
205 final String flavorSubtype = eq("*", flavor.getSubtype())
206 ? extraction.track.getFlavor().getSubtype()
207 : flavor.getSubtype();
208 image.setFlavor(new MediaPackageElementFlavor(flavorType, flavorSubtype));
209 logger.debug("Resulting image has flavor '{}'", image.getFlavor());
210 }
211
212 try {
213 image.setMimeType(MimeTypes.fromURI(image.getURI()));
214 } catch (UnknownFileTypeException e) {
215 logger.warn("Mime type unknown for file {}. Setting none.", image.getURI(), e);
216 }
217
218 applyTargetTagsToElement(cfg.targetImageTags, image);
219 }
220
221
222 protected String createFileName(final String suffix, final URI trackUri, final MediaPosition pos, Cfg cfg)
223 throws WorkflowOperationException {
224 final String trackBaseName = FilenameUtils.getBaseName(trackUri.getPath());
225 final String format;
226 switch (pos.type) {
227 case Seconds:
228 format = cfg.targetBaseNameFormatSecond.orElse(trackBaseName + "_%.3fs%s");
229 break;
230 case Percentage:
231 format = cfg.targetBaseNameFormatPercent.orElse(trackBaseName + "_%.1fp%s");
232 break;
233 default:
234 throw new WorkflowOperationException("Unexhaustive match");
235 }
236 return formatFileName(format, pos.position, suffix);
237 }
238
239
240
241 protected void moveToWorkspace(final ImageWorkflowOperationHandler handler, final MediaPackage mp,
242 final Attachment image, final String fileName) throws WorkflowOperationException {
243 try {
244 image.setURI(handler.workspace.moveTo(
245 image.getURI(),
246 mp.getIdentifier().toString(),
247 image.getIdentifier(),
248 fileName));
249 } catch (Exception e) {
250 throw new WorkflowOperationException(e);
251 }
252 }
253
254
255 protected Job extractImages(
256 final ImageWorkflowOperationHandler handler,
257 final Track track,
258 final EncodingProfile profile,
259 final List<MediaPosition> positions,
260 Cfg cfg
261 ) throws WorkflowOperationException {
262 List<Double> seconds = new ArrayList<>();
263 for (MediaPosition mediaPosition : positions) {
264 seconds.add(toSeconds(track, mediaPosition, cfg.endMargin));
265 }
266
267 try {
268 return handler.composerService.image(track, profile.getIdentifier(), Collections.toDoubleArray(seconds));
269 } catch (Exception e) {
270 throw new WorkflowOperationException("Error starting image extraction job", e);
271 }
272 }
273
274
275
276
277
278
279 static String formatFileName(String format, double position, String suffix) {
280 return format(Locale.ROOT, format, position, suffix);
281 }
282
283
284
285 private static List<Job> concatJobs(List<Extraction> extractions) {
286 List<Job> jobs = new ArrayList<>();
287 for (Extraction extraction : extractions) {
288 jobs.add(extraction.job);
289 }
290 return jobs;
291 }
292
293
294 @SuppressWarnings("unchecked")
295 private static List<Attachment> getImages(Job job) throws MediaPackageException, WorkflowOperationException {
296 final List<Attachment> images;
297 images = (List<Attachment>) MediaPackageElementParser.getArrayFromXml(job.getPayload());
298 if (!images.isEmpty()) {
299 return images;
300 } else {
301 throw new WorkflowOperationException("Job did not extract any images");
302 }
303 }
304
305
306 static List<MediaPosition> limit(Track track, List<MediaPosition> positions) {
307 final Long duration = track.getDuration();
308
309
310 if (duration == null || (track.getStreams() != null && Arrays.stream(track.getStreams())
311 .filter(stream -> stream instanceof VideoStream)
312 .map(org.opencastproject.mediapackage.Stream::getFrameCount)
313 .allMatch(frameCount -> frameCount == null || frameCount == 1))) {
314 return java.util.Collections.singletonList(new MediaPosition(PositionType.Seconds, 0));
315 }
316
317 return positions.stream()
318 .filter(p -> (PositionType.Seconds.equals(p.type) && p.position >= 0 && p.position < duration)
319 || (PositionType.Percentage.equals(p.type) && p.position >= 0 && p.position <= 100))
320 .collect(Collectors.toList());
321 }
322
323
324
325
326
327
328 static double toSeconds(Track track, MediaPosition position, double endMarginMs) throws WorkflowOperationException {
329 final long durationMs = track.getDuration() == null ? 0 : track.getDuration();
330 final double posMs;
331 switch (position.type) {
332 case Percentage:
333 posMs = durationMs * position.position / 100.0;
334 break;
335 case Seconds:
336 posMs = position.position * 1000.0;
337 break;
338 default:
339 throw new IllegalArgumentException("Unhandled MediaPosition type: " + position.type);
340 }
341
342 return Math.abs(durationMs - posMs) >= endMarginMs
343 ? posMs / 1000.0
344 : Math.max(0, ((double) durationMs - endMarginMs)) / 1000.0;
345 }
346
347
348
349
350
351
352
353 public static EncodingProfile fetchProfile(ComposerService composerService, String profileName)
354 throws WorkflowOperationException {
355 EncodingProfile profile = composerService.getProfile(profileName);
356 if (profile == null) {
357 throw new WorkflowOperationException("Encoding profile '" + profileName + "' was not found");
358 }
359 return profile;
360 }
361
362
363
364
365
366 static final class Extraction {
367
368 private final Job job;
369
370 private final Track track;
371
372 private final EncodingProfile profile;
373
374 private final List<MediaPosition> positions;
375
376 private Extraction(Job job, Track track, EncodingProfile profile, List<MediaPosition> positions) {
377 this.job = job;
378 this.track = track;
379 this.profile = profile;
380 this.positions = positions;
381 }
382 }
383
384
385
386
387
388
389 static final class Cfg {
390
391 private final List<Track> sourceTracks;
392 private final List<MediaPosition> positions;
393 private final List<EncodingProfile> profiles;
394 private final List<MediaPackageElementFlavor> targetImageFlavor;
395 private final ConfiguredTagsAndFlavors.TargetTags targetImageTags;
396 private final Optional<String> targetBaseNameFormatSecond;
397 private final Optional<String> targetBaseNameFormatPercent;
398 private final long endMargin;
399
400 Cfg(List<Track> sourceTracks,
401 List<MediaPosition> positions,
402 List<EncodingProfile> profiles,
403 List<MediaPackageElementFlavor> targetImageFlavor,
404 ConfiguredTagsAndFlavors.TargetTags targetImageTags,
405 Optional<String> targetBaseNameFormatSecond,
406 Optional<String> targetBaseNameFormatPercent,
407 long endMargin) {
408 this.sourceTracks = sourceTracks;
409 this.positions = positions;
410 this.profiles = profiles;
411 this.targetImageFlavor = targetImageFlavor;
412 this.targetImageTags = targetImageTags;
413 this.endMargin = endMargin;
414 this.targetBaseNameFormatSecond = targetBaseNameFormatSecond;
415 this.targetBaseNameFormatPercent = targetBaseNameFormatPercent;
416 }
417 }
418
419
420 private Cfg configure(MediaPackage mp, WorkflowInstance wi) throws WorkflowOperationException {
421 WorkflowOperationInstance woi = wi.getCurrentOperation();
422 ConfiguredTagsAndFlavors tagsAndFlavors = getTagsAndFlavors(wi,
423 Configuration.many, Configuration.many, Configuration.many, Configuration.one);
424 final List<EncodingProfile> profiles = getOptConfig(woi, OPT_PROFILES)
425 .map(config -> Arrays.asList(config.split(",")))
426 .orElse(java.util.Collections.emptyList())
427 .stream()
428 .map(String::trim)
429 .filter(profileName -> !profileName.isEmpty())
430 .map(profileName -> {
431 try {
432 return fetchProfile(composerService, profileName);
433 } catch (WorkflowOperationException e) {
434 throw new RuntimeException(e);
435 }
436 })
437 .collect(Collectors.toList());
438 final ConfiguredTagsAndFlavors.TargetTags targetImageTags = tagsAndFlavors.getTargetTags();
439 final List<MediaPackageElementFlavor> targetImageFlavor = tagsAndFlavors.getTargetFlavors();
440 final List<Track> sourceTracks;
441 {
442
443 final List<String> sourceTags = tagsAndFlavors.getSrcTags();
444 final List<MediaPackageElementFlavor> sourceFlavors = tagsAndFlavors.getSrcFlavors();
445 TrackSelector trackSelector = new TrackSelector();
446
447
448 for (String tag : sourceTags) {
449 trackSelector.addTag(tag);
450 }
451 for (MediaPackageElementFlavor flavor : sourceFlavors) {
452 trackSelector.addFlavor(flavor);
453 }
454
455
456 sourceTracks = trackSelector.select(mp, true).stream()
457 .filter(Track::hasVideo)
458 .collect(Collectors.toList());
459 }
460 final List<MediaPosition> positions = parsePositions(getConfig(woi, OPT_POSITIONS));
461 final long endMargin = getOptConfig(woi, OPT_END_MARGIN)
462 .map(Long::parseLong)
463 .orElse(END_MARGIN_DEFAULT);
464 return new Cfg(sourceTracks,
465 positions,
466 profiles,
467 targetImageFlavor,
468 targetImageTags,
469 getTargetBaseNameFormat(woi, OPT_TARGET_BASE_NAME_FORMAT_SECOND),
470 getTargetBaseNameFormat(woi, OPT_TARGET_BASE_NAME_FORMAT_PERCENT),
471 endMargin);
472 }
473
474
475 private Optional<String> getTargetBaseNameFormat(WorkflowOperationInstance woi, final String formatName)
476 throws WorkflowOperationException {
477 Optional<String> baseName = getOptConfig(woi, formatName);
478 if (baseName.isPresent()) {
479 baseName = Optional.ofNullable(validateTargetBaseNameFormat(baseName.get(), formatName));
480 }
481 return baseName;
482 }
483
484 static String validateTargetBaseNameFormat(String format, final String formatName) throws WorkflowOperationException {
485 boolean valid;
486 String name = null;
487 try {
488 name = formatFileName(format, 15.11, ".png");
489 valid = name.contains(".") && name.contains(".png");
490 } catch (IllegalFormatException e) {
491 valid = false;
492 }
493 if (!valid || name == null) {
494 throw new WorkflowOperationException(format(
495 "%s is not a valid format string for config option %s",
496 format, formatName));
497 }
498
499 return name;
500 }
501
502
503
504
505
506
507 static final class MediaPositionParser {
508
509 public static List<MediaPosition> parsePositions(String input) {
510 List<MediaPosition> positions = new ArrayList<>();
511 int index = 0;
512 int length = input.length();
513
514 while (index < length) {
515
516 while (index < length && (Character.isWhitespace(input.charAt(index)) || input.charAt(index) == ',')) {
517 index++;
518 }
519
520 if (index >= length) {
521 break;
522 }
523
524
525 int start = index;
526 if (input.charAt(index) == '-') {
527 index++;
528 }
529
530 boolean dotSeen = false;
531 while (index < length) {
532 char c = input.charAt(index);
533 if (Character.isDigit(c)) {
534 index++;
535 } else if (c == '.' && !dotSeen) {
536 dotSeen = true;
537 index++;
538 } else {
539 break;
540 }
541 }
542
543 if (start == index || (input.charAt(start) == '-' && start + 1 == index)) {
544 throw new IllegalArgumentException("Expected number at position " + start);
545 }
546
547 double value = Double.parseDouble(input.substring(start, index));
548
549
550 boolean isPercentage = false;
551 if (index < length && input.charAt(index) == '%') {
552 isPercentage = true;
553 index++;
554 }
555
556 PositionType type = isPercentage ? PositionType.Percentage : PositionType.Seconds;
557 positions.add(new MediaPosition(type, value));
558 }
559
560 return positions;
561 }
562 }
563
564 private List<MediaPosition> parsePositions(String time) throws WorkflowOperationException {
565 final List<MediaPosition> r = MediaPositionParser.parsePositions(time);
566 if (!r.isEmpty()) {
567 return r;
568 } else {
569 throw new WorkflowOperationException(format("Cannot parse time string %s.", time));
570 }
571 }
572
573 enum PositionType {
574 Percentage, Seconds
575 }
576
577
578
579
580 static final class MediaPosition {
581 private double position;
582 private final PositionType type;
583
584 MediaPosition(PositionType type, double position) {
585 this.position = position;
586 this.type = type;
587 }
588
589 public void setPosition(double position) {
590 this.position = position;
591 }
592
593 @Override public int hashCode() {
594 return Objects.hash(position, type);
595 }
596
597 @Override public boolean equals(Object that) {
598 return (this == that) || (that instanceof MediaPosition && eqFields((MediaPosition) that));
599 }
600
601 private boolean eqFields(MediaPosition that) {
602 return position == that.position && eq(type, that.type);
603 }
604
605 @Override public String toString() {
606 return format("MediaPosition(%s, %s)", type, position);
607 }
608 }
609
610 @Reference
611 @Override
612 public void setServiceRegistry(ServiceRegistry serviceRegistry) {
613 super.setServiceRegistry(serviceRegistry);
614 }
615
616 }