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 protected void moveToWorkspace(final ImageWorkflowOperationHandler handler, final MediaPackage mp,
241 final Attachment image, final String fileName) throws WorkflowOperationException {
242 try {
243 image.setURI(handler.workspace.moveTo(
244 image.getURI(),
245 mp.getIdentifier().toString(),
246 image.getIdentifier(),
247 fileName));
248 } catch (Exception e) {
249 throw new WorkflowOperationException(e);
250 }
251 }
252
253
254 protected Job extractImages(
255 final ImageWorkflowOperationHandler handler,
256 final Track track,
257 final EncodingProfile profile,
258 final List<MediaPosition> positions,
259 Cfg cfg
260 ) throws WorkflowOperationException {
261 List<Double> seconds = new ArrayList<>();
262 for (MediaPosition mediaPosition : positions) {
263 seconds.add(toSeconds(track, mediaPosition, cfg.endMargin));
264 }
265
266 try {
267 return handler.composerService.image(track, profile.getIdentifier(), Collections.toDoubleArray(seconds));
268 } catch (Exception e) {
269 throw new WorkflowOperationException("Error starting image extraction job", e);
270 }
271 }
272
273
274
275
276
277
278 static String formatFileName(String format, double position, String suffix) {
279 return format(Locale.ROOT, format, position, suffix);
280 }
281
282
283
284 private static List<Job> concatJobs(List<Extraction> extractions) {
285 List<Job> jobs = new ArrayList<>();
286 for (Extraction extraction : extractions) {
287 jobs.add(extraction.job);
288 }
289 return jobs;
290 }
291
292
293 @SuppressWarnings("unchecked")
294 private static List<Attachment> getImages(Job job) throws MediaPackageException, WorkflowOperationException {
295 final List<Attachment> images;
296 images = (List<Attachment>) MediaPackageElementParser.getArrayFromXml(job.getPayload());
297 if (!images.isEmpty()) {
298 return images;
299 } else {
300 throw new WorkflowOperationException("Job did not extract any images");
301 }
302 }
303
304
305 static List<MediaPosition> limit(Track track, List<MediaPosition> positions) {
306 final Long duration = track.getDuration();
307
308
309 if (duration == null || (track.getStreams() != null && Arrays.stream(track.getStreams())
310 .filter(stream -> stream instanceof VideoStream)
311 .map(org.opencastproject.mediapackage.Stream::getFrameCount)
312 .allMatch(frameCount -> frameCount == null || frameCount == 1))) {
313 return java.util.Collections.singletonList(new MediaPosition(PositionType.Seconds, 0));
314 }
315
316 return positions.stream()
317 .filter(p -> (PositionType.Seconds.equals(p.type) && p.position >= 0 && p.position < duration)
318 || (PositionType.Percentage.equals(p.type) && p.position >= 0 && p.position <= 100))
319 .collect(Collectors.toList());
320 }
321
322
323
324
325
326
327 static double toSeconds(Track track, MediaPosition position, double endMarginMs) throws WorkflowOperationException {
328 final long durationMs = track.getDuration() == null ? 0 : track.getDuration();
329 final double posMs;
330 switch (position.type) {
331 case Percentage:
332 posMs = durationMs * position.position / 100.0;
333 break;
334 case Seconds:
335 posMs = position.position * 1000.0;
336 break;
337 default:
338 throw new IllegalArgumentException("Unhandled MediaPosition type: " + position.type);
339 }
340
341 return Math.abs(durationMs - posMs) >= endMarginMs
342 ? posMs / 1000.0
343 : Math.max(0, ((double) durationMs - endMarginMs)) / 1000.0;
344 }
345
346
347
348
349
350
351
352 public static EncodingProfile fetchProfile(ComposerService composerService, String profileName)
353 throws WorkflowOperationException {
354 EncodingProfile profile = composerService.getProfile(profileName);
355 if (profile == null) {
356 throw new WorkflowOperationException("Encoding profile '" + profileName + "' was not found");
357 }
358 return profile;
359 }
360
361
362
363
364
365 static final class Extraction {
366
367 private final Job job;
368
369 private final Track track;
370
371 private final EncodingProfile profile;
372
373 private final List<MediaPosition> positions;
374
375 private Extraction(Job job, Track track, EncodingProfile profile, List<MediaPosition> positions) {
376 this.job = job;
377 this.track = track;
378 this.profile = profile;
379 this.positions = positions;
380 }
381 }
382
383
384
385
386
387
388 static final class Cfg {
389
390 private final List<Track> sourceTracks;
391 private final List<MediaPosition> positions;
392 private final List<EncodingProfile> profiles;
393 private final List<MediaPackageElementFlavor> targetImageFlavor;
394 private final ConfiguredTagsAndFlavors.TargetTags targetImageTags;
395 private final Optional<String> targetBaseNameFormatSecond;
396 private final Optional<String> targetBaseNameFormatPercent;
397 private final long endMargin;
398
399 Cfg(List<Track> sourceTracks,
400 List<MediaPosition> positions,
401 List<EncodingProfile> profiles,
402 List<MediaPackageElementFlavor> targetImageFlavor,
403 ConfiguredTagsAndFlavors.TargetTags targetImageTags,
404 Optional<String> targetBaseNameFormatSecond,
405 Optional<String> targetBaseNameFormatPercent,
406 long endMargin) {
407 this.sourceTracks = sourceTracks;
408 this.positions = positions;
409 this.profiles = profiles;
410 this.targetImageFlavor = targetImageFlavor;
411 this.targetImageTags = targetImageTags;
412 this.endMargin = endMargin;
413 this.targetBaseNameFormatSecond = targetBaseNameFormatSecond;
414 this.targetBaseNameFormatPercent = targetBaseNameFormatPercent;
415 }
416 }
417
418
419 private Cfg configure(MediaPackage mp, WorkflowInstance wi) throws WorkflowOperationException {
420 WorkflowOperationInstance woi = wi.getCurrentOperation();
421 ConfiguredTagsAndFlavors tagsAndFlavors = getTagsAndFlavors(wi,
422 Configuration.many, Configuration.many, Configuration.many, Configuration.one);
423 final List<EncodingProfile> profiles = getOptConfig(woi, OPT_PROFILES)
424 .map(config -> Arrays.asList(config.split(",")))
425 .orElse(java.util.Collections.emptyList())
426 .stream()
427 .map(String::trim)
428 .filter(profileName -> !profileName.isEmpty())
429 .map(profileName -> {
430 try {
431 return fetchProfile(composerService, profileName);
432 } catch (WorkflowOperationException e) {
433 throw new RuntimeException(e);
434 }
435 })
436 .collect(Collectors.toList());
437 final ConfiguredTagsAndFlavors.TargetTags targetImageTags = tagsAndFlavors.getTargetTags();
438 final List<MediaPackageElementFlavor> targetImageFlavor = tagsAndFlavors.getTargetFlavors();
439 final List<Track> sourceTracks;
440 {
441
442 final List<String> sourceTags = tagsAndFlavors.getSrcTags();
443 final List<MediaPackageElementFlavor> sourceFlavors = tagsAndFlavors.getSrcFlavors();
444 TrackSelector trackSelector = new TrackSelector();
445
446
447 for (String tag : sourceTags) {
448 trackSelector.addTag(tag);
449 }
450 for (MediaPackageElementFlavor flavor : sourceFlavors) {
451 trackSelector.addFlavor(flavor);
452 }
453
454
455 sourceTracks = trackSelector.select(mp, true).stream()
456 .filter(Track::hasVideo)
457 .collect(Collectors.toList());
458 }
459 final List<MediaPosition> positions = parsePositions(getConfig(woi, OPT_POSITIONS));
460 final long endMargin = getOptConfig(woi, OPT_END_MARGIN)
461 .map(Long::parseLong)
462 .orElse(END_MARGIN_DEFAULT);
463 return new Cfg(sourceTracks,
464 positions,
465 profiles,
466 targetImageFlavor,
467 targetImageTags,
468 getTargetBaseNameFormat(woi, OPT_TARGET_BASE_NAME_FORMAT_SECOND),
469 getTargetBaseNameFormat(woi, OPT_TARGET_BASE_NAME_FORMAT_PERCENT),
470 endMargin);
471 }
472
473
474 private Optional<String> getTargetBaseNameFormat(WorkflowOperationInstance woi, final String formatName)
475 throws WorkflowOperationException {
476 Optional<String> baseName = getOptConfig(woi, formatName);
477 if (baseName.isPresent()) {
478 baseName = Optional.ofNullable(validateTargetBaseNameFormat(baseName.get(), formatName));
479 }
480 return baseName;
481 }
482
483 static String validateTargetBaseNameFormat(String format, final String formatName) throws WorkflowOperationException {
484 boolean valid;
485 String name = null;
486 try {
487 name = formatFileName(format, 15.11, ".png");
488 valid = name.contains(".") && name.contains(".png");
489 } catch (IllegalFormatException e) {
490 valid = false;
491 }
492 if (!valid || name == null) {
493 throw new WorkflowOperationException(format(
494 "%s is not a valid format string for config option %s",
495 format, formatName));
496 }
497
498 return name;
499 }
500
501
502
503
504
505
506 static final class MediaPositionParser {
507
508 public static List<MediaPosition> parsePositions(String input) {
509 List<MediaPosition> positions = new ArrayList<>();
510 int index = 0;
511 int length = input.length();
512
513 while (index < length) {
514
515 while (index < length && (Character.isWhitespace(input.charAt(index)) || input.charAt(index) == ',')) {
516 index++;
517 }
518
519 if (index >= length) break;
520
521
522 int start = index;
523 if (input.charAt(index) == '-') {
524 index++;
525 }
526
527 boolean dotSeen = false;
528 while (index < length) {
529 char c = input.charAt(index);
530 if (Character.isDigit(c)) {
531 index++;
532 } else if (c == '.' && !dotSeen) {
533 dotSeen = true;
534 index++;
535 } else {
536 break;
537 }
538 }
539
540 if (start == index || (input.charAt(start) == '-' && start + 1 == index)) {
541 throw new IllegalArgumentException("Expected number at position " + start);
542 }
543
544 double value = Double.parseDouble(input.substring(start, index));
545
546
547 boolean isPercentage = false;
548 if (index < length && input.charAt(index) == '%') {
549 isPercentage = true;
550 index++;
551 }
552
553 PositionType type = isPercentage ? PositionType.Percentage : PositionType.Seconds;
554 positions.add(new MediaPosition(type, value));
555 }
556
557 return positions;
558 }
559 }
560
561 private List<MediaPosition> parsePositions(String time) throws WorkflowOperationException {
562 final List<MediaPosition> r = MediaPositionParser.parsePositions(time);
563 if (!r.isEmpty()) {
564 return r;
565 } else {
566 throw new WorkflowOperationException(format("Cannot parse time string %s.", time));
567 }
568 }
569
570 enum PositionType {
571 Percentage, Seconds
572 }
573
574
575
576
577 static final class MediaPosition {
578 private double position;
579 private final PositionType type;
580
581 MediaPosition(PositionType type, double position) {
582 this.position = position;
583 this.type = type;
584 }
585
586 public void setPosition(double position) {
587 this.position = position;
588 }
589
590 @Override public int hashCode() {
591 return Objects.hash(position, type);
592 }
593
594 @Override public boolean equals(Object that) {
595 return (this == that) || (that instanceof MediaPosition && eqFields((MediaPosition) that));
596 }
597
598 private boolean eqFields(MediaPosition that) {
599 return position == that.position && eq(type, that.type);
600 }
601
602 @Override public String toString() {
603 return format("MediaPosition(%s, %s)", type, position);
604 }
605 }
606
607 @Reference
608 @Override
609 public void setServiceRegistry(ServiceRegistry serviceRegistry) {
610 super.setServiceRegistry(serviceRegistry);
611 }
612
613 }