View Javadoc
1   /*
2    * Licensed to The Apereo Foundation under one or more contributor license
3    * agreements. See the NOTICE file distributed with this work for additional
4    * information regarding copyright ownership.
5    *
6    *
7    * The Apereo Foundation licenses this file to you under the Educational
8    * Community License, Version 2.0 (the "License"); you may not use this file
9    * except in compliance with the License. You may obtain a copy of the License
10   * at:
11   *
12   *   http://opensource.org/licenses/ecl2.txt
13   *
14   * Unless required by applicable law or agreed to in writing, software
15   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
16   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
17   * License for the specific language governing permissions and limitations under
18   * the License.
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   * The workflow definition for handling "image" operations
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    /** The logging facility */
85    private static final Logger logger = LoggerFactory.getLogger(ImageWorkflowOperationHandler.class);
86  
87    // legacy option
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    /** The composer service */
98    private ComposerService composerService = null;
99  
100   /** The local workspace */
101   private Workspace workspace = null;
102 
103   /**
104    * Callback for the OSGi declarative services configuration.
105    *
106    * @param composerService
107    *          the composer service
108    */
109   @Reference
110   protected void setComposerService(ComposerService composerService) {
111     this.composerService = composerService;
112   }
113 
114   /**
115    * Callback for declarative services configuration that will introduce us to the local workspace service.
116    * Implementation assumes that the reference is configured as being static.
117    *
118    * @param workspace
119    *          an instance of the workspace
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       // Extract
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       // start image extraction jobs
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         // create one extraction per encoding profile
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         // all extractions were successful; iterate them
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             // post process images
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             // fewer images than expected have been extracted
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    * Adjust flavor, tags, mime type of <code>image</code> according to the
197    * configuration and the extraction.
198    */
199   protected void adjustMetadata(Extraction extraction, Attachment image, Cfg cfg) {
200     // Adjust the target flavor. Make sure to account for partial updates
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     // Set the mime type
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     // Add tags
218     applyTargetTagsToElement(cfg.targetImageTags, image);
219   }
220 
221   /** Create a file name for the extracted image. */
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   /** Move the extracted <code>image</code> to its final location in the workspace and rename it to
240    * <code>fileName</code>. */
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   /** Start a composer job to extract images from a track at the given positions. */
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    * Format a filename and make it "safe".
278    */
279   static String formatFileName(String format, double position, String suffix) {
280     return format(Locale.ROOT, format, position, suffix);
281   }
282 
283 
284   /** Concat the jobs of a list of extraction objects. */
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   /** Get the images (payload) from a job. */
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   /** Limit the list of media positions to those that fit into the length of the track. */
306   static List<MediaPosition> limit(Track track, List<MediaPosition> positions) {
307     final Long duration = track.getDuration();
308     // if the video has just one frame (e.g.: MP3-Podcasts) it makes no sense to go to a certain position
309     // as the video has only one image at position 0
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    * Convert a <code>position</code> into seconds in relation to the given track.
325    * <em>Attention:</em> The function does not check if the calculated absolute position is within
326    * the bounds of the tracks length.
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     // limit maximum position to Xms before the end of the video
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    * Fetch a profile from the composer service. Throw a WorkflowOperationException in case the profile
351    * does not exist.
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    * Describes the extraction of a list of images from a track, extracted after a certain encoding profile.
364    * Track -> (profile, positions)
365    */
366   static final class Extraction {
367     /** The extraction job. */
368     private final Job job;
369     /** The track to extract from. */
370     private final Track track;
371     /** The encoding profile to use for extraction. */
372     private final EncodingProfile profile;
373     /** Media positions. */
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    * The WOH's configuration options.
388    */
389   static final class Cfg {
390     /** List of source tracks, with duration. */
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   /** Get and parse the configuration options. */
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       // get the source tags
443       final List<String> sourceTags = tagsAndFlavors.getSrcTags();
444       final List<MediaPackageElementFlavor> sourceFlavors = tagsAndFlavors.getSrcFlavors();
445       TrackSelector trackSelector = new TrackSelector();
446 
447       //add tags and flavors to TrackSelector
448       for (String tag : sourceTags) {
449         trackSelector.addTag(tag);
450       }
451       for (MediaPackageElementFlavor flavor : sourceFlavors) {
452         trackSelector.addFlavor(flavor);
453       }
454 
455       // select the tracks based on source flavors and tags and skip those that don't have video
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   /** Validate a target base name format. */
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    * Parse media position parameter strings.
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         // Skip any separators (whitespace or commas)
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         // Parse optional minus sign
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         // Check for optional percent sign
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    * A position in time in a media file.
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 }