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