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 <code>fileName</code>. */
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   /** Start a composer job to extract images from a track at the given positions. */
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    * Format a filename and make it "safe".
277    */
278   static String formatFileName(String format, double position, String suffix) {
279     return format(Locale.ROOT, format, position, suffix);
280   }
281 
282 
283   /** Concat the jobs of a list of extraction objects. */
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   /** Get the images (payload) from a job. */
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   /** Limit the list of media positions to those that fit into the length of the track. */
305   static List<MediaPosition> limit(Track track, List<MediaPosition> positions) {
306     final Long duration = track.getDuration();
307     // if the video has just one frame (e.g.: MP3-Podcasts) it makes no sense to go to a certain position
308     // as the video has only one image at position 0
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    * Convert a <code>position</code> into seconds in relation to the given track.
324    * <em>Attention:</em> The function does not check if the calculated absolute position is within
325    * the bounds of the tracks length.
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     // limit maximum position to Xms before the end of the video
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    * Fetch a profile from the composer service. Throw a WorkflowOperationException in case the profile
350    * does not exist.
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    * Describes the extraction of a list of images from a track, extracted after a certain encoding profile.
363    * Track -> (profile, positions)
364    */
365   static final class Extraction {
366     /** The extraction job. */
367     private final Job job;
368     /** The track to extract from. */
369     private final Track track;
370     /** The encoding profile to use for extraction. */
371     private final EncodingProfile profile;
372     /** Media positions. */
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    * The WOH's configuration options.
387    */
388   static final class Cfg {
389     /** List of source tracks, with duration. */
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   /** Get and parse the configuration options. */
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       // get the source tags
442       final List<String> sourceTags = tagsAndFlavors.getSrcTags();
443       final List<MediaPackageElementFlavor> sourceFlavors = tagsAndFlavors.getSrcFlavors();
444       TrackSelector trackSelector = new TrackSelector();
445 
446       //add tags and flavors to TrackSelector
447       for (String tag : sourceTags) {
448         trackSelector.addTag(tag);
449       }
450       for (MediaPackageElementFlavor flavor : sourceFlavors) {
451         trackSelector.addFlavor(flavor);
452       }
453 
454       // select the tracks based on source flavors and tags and skip those that don't have video
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   /** Validate a target base name format. */
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    * Parse media position parameter strings.
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         // Skip any separators (whitespace or commas)
515         while (index < length && (Character.isWhitespace(input.charAt(index)) || input.charAt(index) == ',')) {
516           index++;
517         }
518 
519         if (index >= length) break;
520 
521         // Parse optional minus sign
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         // Check for optional percent sign
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    * A position in time in a media file.
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 }