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 org.opencastproject.composer.api.ComposerService;
25  import org.opencastproject.composer.api.EncoderException;
26  import org.opencastproject.composer.api.EncodingProfile;
27  import org.opencastproject.job.api.Job;
28  import org.opencastproject.job.api.JobContext;
29  import org.opencastproject.mediapackage.MediaPackage;
30  import org.opencastproject.mediapackage.MediaPackageElementFlavor;
31  import org.opencastproject.mediapackage.MediaPackageElementParser;
32  import org.opencastproject.mediapackage.MediaPackageException;
33  import org.opencastproject.mediapackage.Track;
34  import org.opencastproject.mediapackage.selector.TrackSelector;
35  import org.opencastproject.serviceregistry.api.ServiceRegistry;
36  import org.opencastproject.util.NotFoundException;
37  import org.opencastproject.workflow.api.AbstractWorkflowOperationHandler;
38  import org.opencastproject.workflow.api.ConfiguredTagsAndFlavors;
39  import org.opencastproject.workflow.api.WorkflowInstance;
40  import org.opencastproject.workflow.api.WorkflowOperationException;
41  import org.opencastproject.workflow.api.WorkflowOperationHandler;
42  import org.opencastproject.workflow.api.WorkflowOperationInstance;
43  import org.opencastproject.workflow.api.WorkflowOperationResult;
44  import org.opencastproject.workflow.api.WorkflowOperationResult.Action;
45  import org.opencastproject.workspace.api.Workspace;
46  
47  import org.apache.commons.lang3.StringUtils;
48  import org.osgi.service.component.annotations.Component;
49  import org.osgi.service.component.annotations.Reference;
50  import org.slf4j.Logger;
51  import org.slf4j.LoggerFactory;
52  
53  import java.io.IOException;
54  import java.util.Collection;
55  
56  /**
57   * The <code>prepare media</code> operation will make sure that media where audio and video track come in separate files
58   * will be muxed prior to further processing.
59   */
60  @Component(
61      immediate = true,
62      service = WorkflowOperationHandler.class,
63      property = {
64          "service.description=Prepare Media Workflow Operation Handler",
65          "workflow.operation=prepare-av"
66      }
67  )
68  public class PrepareAVWorkflowOperationHandler extends AbstractWorkflowOperationHandler {
69  
70    /** The logging facility */
71    private static final Logger logger = LoggerFactory.getLogger(PrepareAVWorkflowOperationHandler.class);
72    private static final String QUESTION_MARK = "?";
73  
74    /** Name of the 'encode to a/v prepared copy' encoding profile */
75    public static final String PREPARE_AV_PROFILE = "av.copy";
76  
77    /** Name of the muxing encoding profile */
78    public static final String MUX_AV_PROFILE = "mux-av.copy";
79  
80    /** Name of the 'encode to audio only prepared copy' encoding profile */
81    public static final String PREPARE_AONLY_PROFILE = "audio-only.copy";
82  
83    /** Name of the 'encode to video only prepared copy' encoding profile */
84    public static final String PREPARE_VONLY_PROFILE = "video-only.copy";
85  
86    /** Name of the 'rewrite' configuration key */
87    public static final String OPT_REWRITE = "rewrite";
88  
89    /** Name of audio muxing configuration key */
90    public static final String OPT_AUDIO_MUXING_SOURCE_FLAVORS = "audio-muxing-source-flavors";
91  
92    /** The composer service */
93    private ComposerService composerService = null;
94  
95    /** The local workspace */
96    private Workspace workspace = null;
97  
98    /**
99     * Callback for the OSGi declarative services configuration.
100    *
101    * @param composerService
102    *          the local composer service
103    */
104   @Reference
105   protected void setComposerService(ComposerService composerService) {
106     this.composerService = composerService;
107   }
108 
109   /**
110    * Callback for declarative services configuration that will introduce us to the local workspace service.
111    * Implementation assumes that the reference is configured as being static.
112    *
113    * @param workspace
114    *          an instance of the workspace
115    */
116   @Reference
117   public void setWorkspace(Workspace workspace) {
118     this.workspace = workspace;
119   }
120 
121   /**
122    * {@inheritDoc}
123    *
124    * @see org.opencastproject.workflow.api.WorkflowOperationHandler#start(
125    *      org.opencastproject.workflow.api.WorkflowInstance, JobContext)
126    */
127   public WorkflowOperationResult start(final WorkflowInstance workflowInstance, JobContext context)
128           throws WorkflowOperationException {
129     try {
130       logger.debug("Running a/v muxing workflow operation on workflow {}", workflowInstance.getId());
131       return mux(workflowInstance);
132     } catch (Exception e) {
133       throw new WorkflowOperationException(e);
134     }
135   }
136 
137   /**
138    * Merges audio and video track of the selected flavor and adds it to the media package. If there is nothing to mux, a
139    * new track with the target flavor is created (pointing to the original url).
140    *
141    * @param wi
142    *          the mux workflow instance
143    * @return the operation result containing the updated mediapackage
144    * @throws EncoderException
145    *           if encoding fails
146    * @throws IOException
147    *           if read/write operations from and to the workspace fail
148    * @throws NotFoundException
149    *           if the workspace does not contain the requested element
150    */
151   private WorkflowOperationResult mux(WorkflowInstance wi) throws EncoderException,
152           WorkflowOperationException, NotFoundException, MediaPackageException, IOException {
153     MediaPackage src = wi.getMediaPackage();
154     MediaPackage mediaPackage = (MediaPackage) src.clone();
155     WorkflowOperationInstance operation = wi.getCurrentOperation();
156 
157     // Check which tags have been configured
158     ConfiguredTagsAndFlavors tagsAndFlavors = getTagsAndFlavors(wi,
159         Configuration.none, Configuration.one, Configuration.many, Configuration.one);
160 
161     // Read the configuration properties
162     MediaPackageElementFlavor sourceFlavor = tagsAndFlavors.getSingleSrcFlavor();
163     ConfiguredTagsAndFlavors.TargetTags targetTrackTags = tagsAndFlavors.getTargetTags();
164     MediaPackageElementFlavor targetFlavor = tagsAndFlavors.getSingleTargetFlavor();
165     String muxEncodingProfileName = StringUtils.trimToNull(operation.getConfiguration("mux-encoding-profile"));
166     String audioVideoEncodingProfileName = StringUtils.trimToNull(operation.getConfiguration(
167         "audio-video-encoding-profile"));
168     String videoOnlyEncodingProfileName = StringUtils.trimToNull(operation.getConfiguration("video-encoding-profile"));
169     String audioOnlyEncodingProfileName = StringUtils.trimToNull(operation.getConfiguration("audio-encoding-profile"));
170 
171     // Reencode when there is no need for muxing?
172     boolean rewrite = true;
173     if (StringUtils.trimToNull(operation.getConfiguration(OPT_REWRITE)) != null) {
174       rewrite = Boolean.parseBoolean(operation.getConfiguration(OPT_REWRITE));
175     }
176 
177     String audioMuxingSourceFlavors = StringUtils.trimToNull(operation.getConfiguration(
178         OPT_AUDIO_MUXING_SOURCE_FLAVORS));
179 
180     // Select those tracks that have matching flavors
181     TrackSelector trackSelector = new TrackSelector();
182     trackSelector.addFlavor(sourceFlavor);
183     Collection<Track> tracks = trackSelector.select(mediaPackage, false);
184 
185     Track audioTrack = null;
186     Track videoTrack = null;
187 
188     switch (tracks.size()) {
189       case 0:
190         logger.info("No audio/video tracks with flavor '{}' found to prepare", sourceFlavor);
191         return createResult(mediaPackage, Action.CONTINUE);
192       case 1:
193         videoTrack = tracks.iterator().next();
194         if (!videoTrack.hasAudio() && videoTrack.hasVideo() && (audioMuxingSourceFlavors != null)) {
195           audioTrack = findAudioTrack(videoTrack, mediaPackage, audioMuxingSourceFlavors);
196         } else {
197           audioTrack = videoTrack;
198         }
199         break;
200       case 2:
201         for (Track track : tracks) {
202           if (track.hasAudio() && !track.hasVideo()) {
203             audioTrack = track;
204           } else if (!track.hasAudio() && track.hasVideo()) {
205             videoTrack = track;
206           } else {
207             throw new WorkflowOperationException("Multiple tracks with competing audio/video streams and flavor '"
208                     + sourceFlavor + "' found");
209           }
210         }
211         break;
212       default:
213         logger.error("More than two tracks with flavor {} found. No idea what we should be doing", sourceFlavor);
214         throw new WorkflowOperationException("More than two tracks with flavor '" + sourceFlavor + "' found");
215     }
216 
217     Job job = null;
218     Track composedTrack = null;
219 
220     // Make sure we have a matching combination
221     if (audioTrack == null && videoTrack != null) {
222       if (rewrite) {
223         logger.info("Encoding video only track {} to prepared version", videoTrack);
224         if (videoOnlyEncodingProfileName == null) {
225           videoOnlyEncodingProfileName = PREPARE_VONLY_PROFILE;
226         }
227         // Find the encoding profile to make sure the given profile exists
228         EncodingProfile profile = composerService.getProfile(videoOnlyEncodingProfileName);
229         if (profile == null) {
230           throw new IllegalStateException("Encoding profile '" + videoOnlyEncodingProfileName + "' was not found");
231         }
232         composedTrack = prepare(videoTrack, mediaPackage, videoOnlyEncodingProfileName);
233       } else {
234         composedTrack = (Track) videoTrack.clone();
235         composedTrack.setIdentifier(null);
236         mediaPackage.add(composedTrack);
237       }
238     } else if (videoTrack == null && audioTrack != null) {
239       if (rewrite) {
240         logger.info("Encoding audio only track {} to prepared version", audioTrack);
241         if (audioOnlyEncodingProfileName == null) {
242           audioOnlyEncodingProfileName = PREPARE_AONLY_PROFILE;
243         }
244         // Find the encoding profile to make sure the given profile exists
245         EncodingProfile profile = composerService.getProfile(audioOnlyEncodingProfileName);
246         if (profile == null) {
247           throw new IllegalStateException("Encoding profile '" + audioOnlyEncodingProfileName + "' was not found");
248         }
249         composedTrack = prepare(audioTrack, mediaPackage, audioOnlyEncodingProfileName);
250       } else {
251         composedTrack = (Track) audioTrack.clone();
252         composedTrack.setIdentifier(null);
253         mediaPackage.add(composedTrack);
254       }
255     } else if (audioTrack == videoTrack) {
256       if (rewrite) {
257         logger.info("Encoding audiovisual track {} to prepared version", videoTrack);
258         if (audioVideoEncodingProfileName == null) {
259           audioVideoEncodingProfileName = PREPARE_AV_PROFILE;
260         }
261         // Find the encoding profile to make sure the given profile exists
262         EncodingProfile profile = composerService.getProfile(audioVideoEncodingProfileName);
263         if (profile == null) {
264           throw new IllegalStateException("Encoding profile '" + audioVideoEncodingProfileName + "' was not found");
265         }
266         composedTrack = prepare(videoTrack, mediaPackage, audioVideoEncodingProfileName);
267       } else {
268         composedTrack = (Track) videoTrack.clone();
269         composedTrack.setIdentifier(null);
270         mediaPackage.add(composedTrack);
271       }
272     } else {
273       logger.info("Muxing audio and video only track {} to prepared version", videoTrack);
274 
275       if (audioTrack.hasVideo()) {
276         logger.info("Stripping video from track {}", audioTrack);
277         audioTrack = prepare(audioTrack, null, PREPARE_AONLY_PROFILE);
278       }
279 
280       if (muxEncodingProfileName == null) {
281         muxEncodingProfileName = MUX_AV_PROFILE;
282       }
283 
284       // Find the encoding profile
285       EncodingProfile profile = composerService.getProfile(muxEncodingProfileName);
286       if (profile == null) {
287         throw new IllegalStateException("Encoding profile '" + muxEncodingProfileName + "' was not found");
288       }
289 
290       job = composerService.mux(videoTrack, audioTrack, profile.getIdentifier());
291       if (!waitForStatus(job).isSuccess()) {
292         throw new WorkflowOperationException("Muxing video track " + videoTrack + " and audio track " + audioTrack
293                 + " failed");
294       }
295       composedTrack = (Track) MediaPackageElementParser.getFromXml(job.getPayload());
296       mediaPackage.add(composedTrack);
297       String fileName = getFileNameFromElements(videoTrack, composedTrack);
298       composedTrack.setURI(workspace.moveTo(composedTrack.getURI(), mediaPackage.getIdentifier().toString(),
299               composedTrack.getIdentifier(), fileName));
300     }
301 
302     long timeInQueue = 0;
303     if (job != null) {
304       // add this receipt's queue time to the total
305       timeInQueue = job.getQueueTime();
306     }
307 
308     // Update the track's flavor
309     composedTrack.setFlavor(targetFlavor);
310     logger.debug("Composed track has flavor '{}'", composedTrack.getFlavor());
311 
312     // Update the track's tags
313     applyTargetTagsToElement(targetTrackTags, composedTrack);
314 
315     return createResult(mediaPackage, Action.CONTINUE, timeInQueue);
316   }
317 
318   /**
319    * Prepares a video track. If the mediapackage is specified, the prepared track will be added to it.
320    *
321    * @param videoTrack
322    *          the track containing the video
323    * @param mediaPackage
324    *          the mediapackage
325    * @return the rewritten track
326    * @throws WorkflowOperationException
327    * @throws NotFoundException
328    * @throws IOException
329    * @throws EncoderException
330    * @throws MediaPackageException
331    */
332   private Track prepare(Track videoTrack, MediaPackage mediaPackage, String encodingProfile)
333           throws WorkflowOperationException, NotFoundException, IOException, EncoderException, MediaPackageException {
334     Track composedTrack = null;
335     logger.info("Encoding video only track {} to prepared version", videoTrack);
336     Job job = composerService.encode(videoTrack, encodingProfile);
337     if (!waitForStatus(job).isSuccess()) {
338       throw new WorkflowOperationException("Rewriting container for video track " + videoTrack + " failed");
339     }
340     composedTrack = (Track) MediaPackageElementParser.getFromXml(job.getPayload());
341     if (mediaPackage != null) {
342       mediaPackage.add(composedTrack);
343       String fileName = getFileNameFromElements(videoTrack, composedTrack);
344 
345       // Note that the composed track must have an ID before being moved to the mediapackage in the working file
346       // repository. This ID is generated when the track is added to the mediapackage. So the track must be added
347       // to the mediapackage before attempting to move the file.
348       composedTrack.setURI(workspace.moveTo(composedTrack.getURI(), mediaPackage.getIdentifier().toString(),
349               composedTrack.getIdentifier(), fileName));
350     }
351     return composedTrack;
352   }
353 
354   /**
355    * Finds a suitable audio track from the mediapackage by scanning a source flavor sequence
356    *
357    * @param videoTrack
358    *          the video track
359    * @param mediaPackage
360    *          the mediapackage
361    * @param audioMuxingSourceFlavors
362    *          sequence of source flavors where an audio track should be searched for
363    * @return the found audio track
364    */
365   private Track findAudioTrack(Track videoTrack, MediaPackage mediaPackage, String audioMuxingSourceFlavors) {
366 
367     if (audioMuxingSourceFlavors != null) {
368       String type;
369       String subtype;
370       for (String flavorStr : audioMuxingSourceFlavors.split("[\\s,]")) {
371         if (!flavorStr.isEmpty()) {
372           MediaPackageElementFlavor flavor = null;
373           try {
374             flavor = MediaPackageElementFlavor.parseFlavor(flavorStr);
375           } catch (IllegalArgumentException e) {
376             logger.error("The parameter {} contains an invalid flavor: {}", OPT_AUDIO_MUXING_SOURCE_FLAVORS, flavorStr);
377             throw e;
378           }
379           type = (QUESTION_MARK.equals(flavor.getType())) ? videoTrack.getFlavor().getType() : flavor.getType();
380           subtype = (QUESTION_MARK.equals(flavor.getSubtype()))
381               ? videoTrack.getFlavor().getSubtype() : flavor.getSubtype();
382           // Recreate the (possibly) modified flavor
383           flavor = new MediaPackageElementFlavor(type, subtype);
384           for (Track track : mediaPackage.getTracks(flavor)) {
385             if (track.hasAudio()) {
386               logger.info("Audio muxing found audio source {} with flavor {}", track, track.getFlavor());
387               return track;
388             }
389           }
390         }
391       }
392     }
393     return null;
394   }
395 
396   @Reference
397   @Override
398   public void setServiceRegistry(ServiceRegistry serviceRegistry) {
399     super.setServiceRegistry(serviceRegistry);
400   }
401 
402 }